diff --git a/A00_Common/router.ts b/A00_Common/router.ts index 1adace4a..a3c56318 100644 --- a/A00_Common/router.ts +++ b/A00_Common/router.ts @@ -52,7 +52,7 @@ const routeTable: Partial Promise>> = { [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 () => diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index a25738ca..28951438 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -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 은 상세 diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index 820ef552..a8a2a37b 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -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", diff --git a/B05_Profile/B05_Profile_Structures_Schema.py b/B05_Profile/B05_Profile_Structures_Schema.py index c484602f..7e851bba 100644 --- a/B05_Profile/B05_Profile_Structures_Schema.py +++ b/B05_Profile/B05_Profile_Structures_Schema.py @@ -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): diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index 1caa2b97..a4f121fc 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -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): 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 = {}; + // 고른 칸만 싣는다 — 「안 정함」은 빈 값(기본값을 몰래 확정으로 안 바꿈). + 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). diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts index 0bead6cd..da745045 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts @@ -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, 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, ): 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) => void; read: (target: Record) => 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(); + }, }; } diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Ford.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Ford.ts new file mode 100644 index 00000000..641479b5 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Ford.ts @@ -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) => void; + read: (kind: FordKind, target: Record) => 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); + }, + }; +} diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts new file mode 100644 index 00000000..712b5f88 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts @@ -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> = { + 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; +} + +/** 저장할 시설 정보 — 같은 종류면 폼이 모르는 옛 칸을 이어 붙임. */ +export function mergeFacilityOptions( + 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 }; +} diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index e8c1f183..045be36a 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -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가 새 위치로 시설 정보를 승계한다. diff --git a/B05_Profile/B05_Profile_UI_Structures_Fields.ts b/B05_Profile/B05_Profile_UI_Structures_Fields.ts index 4950c574..00a0104b 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Fields.ts @@ -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; diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index b106e926..92f25339 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -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(); diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts index f9fd343e..516ac307 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts @@ -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; diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index c5d1a4a0..d0a4b7d4 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -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 { diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py index 17f00ca6..5619c0fb 100644 --- a/B06_Section/B06_Section_Engine_Culvert.py +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -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"), } diff --git a/B06_Section/B06_Section_Engine_Structures_Wall.py b/B06_Section/B06_Section_Engine_Structures_Wall.py index 637ebcec..0a57a0ae 100644 --- a/B06_Section/B06_Section_Engine_Structures_Wall.py +++ b/B06_Section/B06_Section_Engine_Structures_Wall.py @@ -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 diff --git a/B06_Section/B06_Section_Server_Calc_Node.ts b/B06_Section/B06_Section_Server_Calc_Node.ts index 99099d7c..4fa13fdb 100644 --- a/B06_Section/B06_Section_Server_Calc_Node.ts +++ b/B06_Section/B06_Section_Server_Calc_Node.ts @@ -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 })); diff --git a/B06_Section/B06_Section_Server_Calc_Prebuild.py b/B06_Section/B06_Section_Server_Calc_Prebuild.py index f87df21b..d032de2d 100644 --- a/B06_Section/B06_Section_Server_Calc_Prebuild.py +++ b/B06_Section/B06_Section_Server_Calc_Prebuild.py @@ -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} diff --git a/B06_Section/B06_Section_Structure_Layouts.ts b/B06_Section/B06_Section_Structure_Layouts.ts index 066718ec..726ef730 100644 --- a/B06_Section/B06_Section_Structure_Layouts.ts +++ b/B06_Section/B06_Section_Structure_Layouts.ts @@ -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 => !!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; +} + +/** + * 관(·독립 기슭막이) 주인 측점마다 **실제로 선** 다단 벽 목록(④, 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 = {}; + 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[], diff --git a/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts b/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts index b6e1b8d5..1d4fa376 100644 --- a/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts +++ b/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts @@ -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"); diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts index 37865f31..25955b46 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -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 — 하부 지지 구조물 별도(추가 예정)` diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts index e12be186..0d49b75b 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts @@ -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공구 수량집계표). */ diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts index 3eb2a177..c171f35c 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts @@ -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; diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index 6c484416..059d186f 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -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; diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts index 500d1d40..964cc7a3 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts @@ -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; diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts index d431a628..abd8416e 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts @@ -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[]; diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts b/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts index 734c63d0..74ce333b 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts @@ -27,6 +27,8 @@ export interface FordPavementSpec { depth_m: number | null; /** 유입 → 유출 바닥 경사(%). 비우면 노면 횡단경사를 쓴다. */ slope_pct: number | null; + /** 정본에 안 적혀 기본값으로 그린 칸 「이름 값」 — 카드 머리에 「기본값(미확정)」. */ + defaulted?: string[]; } interface Edge { diff --git a/B06_Section/B06_Section_UI_Cross_Lean.ts b/B06_Section/B06_Section_UI_Cross_Lean.ts new file mode 100644 index 00000000..e6daa9d8 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Lean.ts @@ -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); +} diff --git a/B06_Section/B06_Section_UI_Cross_Revetment.ts b/B06_Section/B06_Section_UI_Cross_Revetment.ts index d6f4578c..8fc0199e 100644 --- a/B06_Section/B06_Section_UI_Cross_Revetment.ts +++ b/B06_Section/B06_Section_UI_Cross_Revetment.ts @@ -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 = { + 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 diff --git a/B06_Section/B06_Section_UI_Cross_Wall.ts b/B06_Section/B06_Section_UI_Cross_Wall.ts index 2e05e2e6..184090ad 100644 --- a/B06_Section/B06_Section_UI_Cross_Wall.ts +++ b/B06_Section/B06_Section_UI_Cross_Wall.ts @@ -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 }, diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index 621d7c6f..b4f5f165 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -166,6 +166,9 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { 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 { 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이 좌측이고 유입 벽은 오르막(계류) 쪽에 선다. diff --git a/B06_Section/B06_Section_UI_Page_Ford_Controls.ts b/B06_Section/B06_Section_UI_Page_Ford_Controls.ts index 9b2e21cd..e399baa2 100644 --- a/B06_Section/B06_Section_UI_Page_Ford_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Ford_Controls.ts @@ -36,6 +36,38 @@ function wingOptions(role: "inlet" | "outlet", patch: WingPatch): Record = { + 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, 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); 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); }, diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 4f53a3de..af64050f 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -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; + +/** 그 종류의 등록부 기본값 `{키: 기본값}` — 스펙 값이 기본값에서 왔는지 가르는 데 씀. */ +function registryDefaults(types: StructureType[], typeId: string): Record { + 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, + wings: ReadonlyArray, +): 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 | undefined, + options: Merged | undefined, ford: NonNullable, -): Record { - const merged: Record = { ...(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, +): 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 | undefined, + options: Merged | undefined, box: NonNullable, -): Record { - const merged: Record = { ...(options ?? {}) }; - merged.body_width_m = box.inner_width_m; - merged.body_height_m = box.inner_height_m; - for (const [wing, prefix] of [ + defaults: Record, +): 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 | 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 | 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 = { ...(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, ); diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_StandardFigure.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_StandardFigure.py deleted file mode 100644 index 53150fea..00000000 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_StandardFigure.py +++ /dev/null @@ -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 diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_StandardSheet.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_StandardSheet.py index 0fdbcc22..6657db23 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_StandardSheet.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_StandardSheet.py @@ -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) diff --git a/B08_Quantity/B08_Quantity_Build_WorkItemMaster.py b/B08_Quantity/B08_Quantity_Build_WorkItemMaster.py index e3a9afa3..e558f59c 100644 --- a/B08_Quantity/B08_Quantity_Build_WorkItemMaster.py +++ b/B08_Quantity/B08_Quantity_Build_WorkItemMaster.py @@ -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) diff --git a/B08_Quantity/B08_Quantity_Build_WorkItemMaster_Forms.py b/B08_Quantity/B08_Quantity_Build_WorkItemMaster_Forms.py index 6e0f4f07..dbac4226 100644 --- a/B08_Quantity/B08_Quantity_Build_WorkItemMaster_Forms.py +++ b/B08_Quantity/B08_Quantity_Build_WorkItemMaster_Forms.py @@ -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%)"), diff --git a/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py b/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py index 63b9639e..d504d52b 100644 --- a/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py +++ b/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py @@ -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} % 반영" diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff.py b/B08_Quantity/B08_Quantity_Engine_Handoff.py index e3b7f446..cd0054e2 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff.py @@ -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 } diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Boundaries.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Boundaries.py index c4e0ad5b..f1ee3631 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Boundaries.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Boundaries.py @@ -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 '(없음)'} — " diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py index 96598873..4d8fa446 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py @@ -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] = { diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py index b0fe9ba5..6316ab95 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py @@ -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 데스크탑 보조 확인). 같은 공종·같은 단가라 # 금액은 안 움직이고 **이름만 맞추는 것**이다. diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows_Prep.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows_Prep.py index 8a65dd2c..fa938f37 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows_Prep.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows_Prep.py @@ -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, } ) diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Spoil.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Spoil.py index 38036c8e..98d3c228 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Spoil.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Spoil.py @@ -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": "", diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Trench.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Trench.py index 0e7ac65d..28cfe826 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Trench.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Trench.py @@ -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 [] diff --git a/B08_Quantity/B08_Quantity_Engine_HaulInputs.py b/B08_Quantity/B08_Quantity_Engine_HaulInputs.py index cc9b4aa3..3e26dd0f 100644 --- a/B08_Quantity/B08_Quantity_Engine_HaulInputs.py +++ b/B08_Quantity/B08_Quantity_Engine_HaulInputs.py @@ -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 []: diff --git a/B08_Quantity/B08_Quantity_Engine_MaterialSummary.py b/B08_Quantity/B08_Quantity_Engine_MaterialSummary.py index 869caf3d..5e025581 100644 --- a/B08_Quantity/B08_Quantity_Engine_MaterialSummary.py +++ b/B08_Quantity/B08_Quantity_Engine_MaterialSummary.py @@ -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), diff --git a/B08_Quantity/B08_Quantity_Engine_ObservedUnit.py b/B08_Quantity/B08_Quantity_Engine_ObservedUnit.py index 1069c131..aab337de 100644 --- a/B08_Quantity/B08_Quantity_Engine_ObservedUnit.py +++ b/B08_Quantity/B08_Quantity_Engine_ObservedUnit.py @@ -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 diff --git a/B08_Quantity/B08_Quantity_Engine_Pipe.py b/B08_Quantity/B08_Quantity_Engine_Pipe.py index 812b3649..ded2e7b5 100644 --- a/B08_Quantity/B08_Quantity_Engine_Pipe.py +++ b/B08_Quantity/B08_Quantity_Engine_Pipe.py @@ -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 diff --git a/B08_Quantity/B08_Quantity_Engine_Preparation.py b/B08_Quantity/B08_Quantity_Engine_Preparation.py index 743111f4..198369a8 100644 --- a/B08_Quantity/B08_Quantity_Engine_Preparation.py +++ b/B08_Quantity/B08_Quantity_Engine_Preparation.py @@ -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 { diff --git a/B08_Quantity/B08_Quantity_Engine_Preparation_TreeWaste.py b/B08_Quantity/B08_Quantity_Engine_Preparation_TreeWaste.py new file mode 100644 index 00000000..9ff72b71 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_Preparation_TreeWaste.py @@ -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] diff --git a/B08_Quantity/B08_Quantity_Engine_SlopeArea.py b/B08_Quantity/B08_Quantity_Engine_SlopeArea.py index 94384282..7261950d 100644 --- a/B08_Quantity/B08_Quantity_Engine_SlopeArea.py +++ b/B08_Quantity/B08_Quantity_Engine_SlopeArea.py @@ -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), } diff --git a/B08_Quantity/B08_Quantity_Engine_SlopeLength.py b/B08_Quantity/B08_Quantity_Engine_SlopeLength.py index 5256c526..9f404359 100644 --- a/B08_Quantity/B08_Quantity_Engine_SlopeLength.py +++ b/B08_Quantity/B08_Quantity_Engine_SlopeLength.py @@ -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, diff --git a/B08_Quantity/B08_Quantity_Engine_StmateRecipe.py b/B08_Quantity/B08_Quantity_Engine_StmateRecipe.py new file mode 100644 index 00000000..a7655e03 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_StmateRecipe.py @@ -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"(? 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"], + }, + } diff --git a/B08_Quantity/B08_Quantity_Engine_StructureFigure.py b/B08_Quantity/B08_Quantity_Engine_StructureFigure.py new file mode 100644 index 00000000..7a14596a --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_StructureFigure.py @@ -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) diff --git a/B08_Quantity/B08_Quantity_Engine_StructureFigure_Small.py b/B08_Quantity/B08_Quantity_Engine_StructureFigure_Small.py new file mode 100644 index 00000000..5ebeda8c --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_StructureFigure_Small.py @@ -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 diff --git a/B08_Quantity/B08_Quantity_Engine_StructureLibrary.py b/B08_Quantity/B08_Quantity_Engine_StructureLibrary.py index e23e3f9a..4d6b8a65 100644 --- a/B08_Quantity/B08_Quantity_Engine_StructureLibrary.py +++ b/B08_Quantity/B08_Quantity_Engine_StructureLibrary.py @@ -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 = [ diff --git a/B08_Quantity/B08_Quantity_Engine_StructurePriceLink.py b/B08_Quantity/B08_Quantity_Engine_StructurePriceLink.py index 976bae3d..193206be 100644 --- a/B08_Quantity/B08_Quantity_Engine_StructurePriceLink.py +++ b/B08_Quantity/B08_Quantity_Engine_StructurePriceLink.py @@ -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"] diff --git a/B08_Quantity/B08_Quantity_Engine_StructureSheet.py b/B08_Quantity/B08_Quantity_Engine_StructureSheet.py index c858db78..fe705ce2 100644 --- a/B08_Quantity/B08_Quantity_Engine_StructureSheet.py +++ b/B08_Quantity/B08_Quantity_Engine_StructureSheet.py @@ -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) diff --git a/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py b/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py index be19dfad..1a3e183b 100644 --- a/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py +++ b/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py @@ -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, diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index 0354465a..fda877bf 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -30,1107 +30,57 @@ from __future__ import annotations -import json -import math -from pathlib import Path -from functools import lru_cache -from dataclasses import dataclass, field from typing import Any, Iterable -from common_util.common_util_structure_face_role import structure_face_role -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, -) +from B08_Quantity.B08_Quantity_Engine_Formwork import annotate as annotate_formwork +from B08_Quantity.B08_Quantity_Engine_Formwork import shoring_status + +# ⓘ 2026-09-14 쪼갬 — 성분 계약 `_Base` · 돌 제원·계수 `_StoneSpec` · 돌쌓기 전개식 `_Masonry`. +# 밖에서 이 모듈 이름으로 가져가던 것은 그대로 닿게 다시 내보냄(부르는 쪽 44곳 불변). from B08_Quantity.B08_Quantity_Engine_ObservedUnit import ( - BASIS_DERIVED, - BASIS_OBSERVED, + BASIS_DERIVED, # noqa: F401 — 다시 내보냄 ObservedUnitTable, billing_of, expand_observed, load_observed_table, ) -from B08_Quantity.B08_Quantity_Engine_Formwork import annotate as annotate_formwork -from B08_Quantity.B08_Quantity_Engine_Formwork import shoring_status +from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Base import ( # noqa: F401 — 다시 내보냄 + DESTINATION, + MIX_COMPONENTS, + RUBBLE_BASE_NAME, + Component, + StructureQuantity, + _num, + is_unconfirmed, + missing_height_reason, +) +from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Masonry import ( # noqa: F401 — 다시 내보냄 + boulder_masonry, + stone_masonry, +) +from B08_Quantity.B08_Quantity_Engine_UnitQuantity_StoneSpec import ( # noqa: F401 — 다시 내보냄 + BACK_LENGTH_KEYS, + BLINDING_THICKNESS_M, + COLLECTED_STONE_KEY, + FILL_CONCRETE_MPA_DEFAULT, + RUBBLE_BASE_THICKNESS_M, + STONE_BACK_LENGTH_TABLE, + STONE_KIND_OPTION, + STONE_MASONRY, + _back_length, + back_length_default_note, + face_slope_ratio, + fill_concrete_mpa, + is_collected_stone, + load_stone_kind_table, + stone_weight_per_m2, + wall_thickness, + wants_blinding, + wants_practice_coefficients, + weep_hole_spec, +) from common_util.common_util_quantity_spread import spread_by_unit - -# ── 계수표 — 식에 박지 않고 여기서 고른다 ───────────────────────────── -# 돌 뒷길이(㎝)별 원단위. 출처: `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"} - -#: ⚠⚠ **채집석 공제는 사토에서 한 번만 뺀다** (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 -RUBBLE_BASE_NAME = "기초잡석" -#: 버림을 뺄 수 있는 칸 — 「기본은 넣고, 빼고 싶으면 뺀다」(사용자 확정 ⑭). -#: 저장 제원에 이 칸이 없으면 **넣는 쪽**이 기본이다. -BLINDING_OPTION_KEYS = ("blinding_concrete", "base_blinding") - -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", -} - -# ⚠ 배합 성분 — 산출물에 나타나면 안 된다(㉢). 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 = "" - #: 양식으로 성분을 세웠으면 그 양식 이름(PLAN 3장 ④-2). 비면 지금 전개 값. - library_item: str = "" - - -#: 표준경사표 — 품셈 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 - - -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, - ) - - -#: ⚠ **저장 제원의 실제 칸 이름**은 `back_len_cm` 이다(레지스트리 확인). -#: 앞서 `stone_back_length_cm` 을 읽고 있어 **저장값이 영영 안 닿고 늘 기본 45㎝ 로 돌았다** -#: — 뒷길이를 75 로 골라도 45 계수가 붙던 자리다. 값이 나오므로 아무 시험도 안 잡았다. -#: 옛 이름도 함께 본다(다른 곳에서 그 이름으로 넣어 줄 수 있다). -BACK_LENGTH_KEYS = ("back_len_cm", "stone_back_length_cm") - - -def _back_length(options: dict[str, Any]) -> 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 - return DEFAULT_BACK_LENGTH_CM - - -def _num(value: Any, fallback: float = 0.0) -> float: - return float(value) if isinstance(value, (int, float)) else fallback - - -#: 벽 두께를 **사용자가 덮어쓰는 칸** — 비우면 실무 구조물도 식으로 돈다(확정 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}" - - -#: 큰돌쌓기(품셈 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) - 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]) - # ⚠ **돌 종류로 계수가 갈린다** (품셈 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 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 - +from common_util.common_util_structure_face_role import structure_face_role_of # 구조물 종류 → 전개식. 없는 종류는 전개하지 않고 이름만 남긴다(지어내지 않는다). # ⚠ **관측 원단위표로 가는 종류** — 치수가 저장돼 있지 않아 전개식을 못 세우는 것들이다. @@ -1214,6 +164,15 @@ def _revetment(): #: 전개식을 일부러 안 두는 종류 — 왜 안 두는지 사람이 읽게 적는다. EXPANDER_WITHHELD: dict[str, str] = {} +#: 전개식이 **개소당·㎡당**으로 값을 내는 종류의 내역 단위와 수량(2026-09-14 구조물도 그림에서 잡음). +#: ⚠ 안 적으면 「m · 연장」으로 읽혀 — 골막이·바닥막이(점 배치라 연장 0)는 **수량 0 이라 내역에 안 서고** +#: 구조물도가 전 줄 「단위당 못 냄」, 떼흙막이는 개소당 값이 연장으로 나뉘어 **떼 0.139/m** 로 보였음. +EXPANDER_BILLING: dict[str, Any] = { + "erosion_check": lambda options: ("개소", 1.0), # 정본 「골막이(찰)(치수조서연결)」 개소당 + "soil_guard": lambda options: ("개소", 1.0), # 정본 「떼흙막이」 개소당(평균 붙박이) + "bed_sill": lambda options: ("㎡", _num(options.get("area_m2"), 0.0)), # 정본 돌붙임 ㎡당 +} + #: 한 구조물이 **여러 내역 줄**을 낳는 자리. 배수관은 관 자체와 유입부 집수정이 따로 선다 #: (품셈도 관부설과 집수정을 다른 공종으로 둔다). 한 줄로 합치면 어느 쪽 물량인지 못 가른다. @@ -1241,24 +200,26 @@ def wing_wall_double_count(options: dict[str, Any]) -> str | None: return WING_WALL_DOUBLE_COUNT if options.get("inlet_basin_form") else None +def child_row(structure: dict[str, Any], type_id: str, label: str, suffix: str = "") -> dict: + """딸린 줄 한 벌 — 이름에 「어디에 딸렸나」가 남음(`expand` 가 부모 이름 · 꼬리로 붙임).""" + return { + **structure, + "structure_id": f"{structure.get('structure_id')}-{suffix or type_id}", + "type_id": type_id, + "attachment_of": structure.get("structure_id"), + "attachment_parent_type": structure.get("type_id"), + "attachment_label": label, + } + + def attachments_of(structure: dict[str, Any]) -> list[dict[str, Any]]: """구조물에 딸린 **별도 줄**을 만든다. 제원은 원본을 그대로 물려준다(치수 두 벌 금지).""" - rows: list[dict[str, Any]] = [] options = structure.get("options") or {} - for type_id, gate_key, label in ATTACHMENTS.get(str(structure.get("type_id") or ""), ()): - if not options.get(gate_key): - continue # 그 부속이 없는 배치다 — 빈 줄을 만들지 않는다 - rows.append( - { - **structure, - "structure_id": f"{structure.get('structure_id')}-{type_id}", - "type_id": type_id, - "attachment_of": structure.get("structure_id"), - "attachment_parent_type": structure.get("type_id"), - "attachment_label": label, - } - ) - return rows + return [ + child_row(structure, type_id, label) + for type_id, gate_key, label in ATTACHMENTS.get(str(structure.get("type_id") or ""), ()) + if options.get(gate_key) # 그 부속이 없는 배치다 — 빈 줄을 만들지 않는다 + ] def _observed_components( @@ -1331,6 +292,10 @@ def expand( end_m=end if structure.get("end_m") is not None else None, options=dict(options), ) + # 줄을 만든 쪽이 붙인 사유(관 지점 시설의 기본값 · 못 가른 자리) — `withheld` 면 값을 안 세움. + result.notes.extend(str(note) for note in structure.get("notes") or []) + if structure.get("withheld"): + return result withheld = EXPANDER_WITHHELD.get(type_id) if withheld: result.notes.append(f"전개식 미확보 — {withheld}") @@ -1354,9 +319,12 @@ def expand( ) return result # 성토/절토 — **판정 한 벌**을 부른다(우리가 따로 짜지 않는다). - face, face_reason = structure_face_role(section_mode, options.get("side")) + face, face_reason = structure_face_role_of(section_mode, options) result.components, notes = expander(height, length, options, face, face_reason) result.notes.extend(notes) + billing = EXPANDER_BILLING.get(type_id) + if billing is not None and result.components: + result.billing_unit, result.billing_quantity = billing(options) return result @@ -1500,6 +468,33 @@ def _rubble_base_component( ) +def _append_section_trench( + quantity: StructureQuantity, + structure: dict[str, Any], + observed: ObservedUnitTable, + thickness_m: float | None, +) -> None: + """단면이 실린 관측 줄(옹벽)의 터파기·되메우기·잔토 — 기초잡석과 **같은 두께**로 깊이를 잡음.""" + from B08_Quantity.B08_Quantity_Engine_ObservedUnit import scale_for, section_trench_components + + keys = OBSERVED_SPEC_KEYS.get(quantity.type_id) + if not keys or not quantity.components: + return + options = structure.get("options") or {} + entry = observed.find( + quantity.type_id, {key: options[key] for key in keys if options.get(key) is not None} + ) + if entry is None: + return + scale, _note = scale_for(entry, structure) + thickness = RUBBLE_BASE_THICKNESS_M if thickness_m is None else float(thickness_m) + found = section_trench_components(entry, max(thickness, 0.0), scale) + if found is None: + return + rows, quantity.trench_depth_m = found + quantity.components.extend(Component(**row) for row in rows) + + def build_table( structures: Iterable[dict[str, Any]], names: dict[str, str] | None = None, @@ -1534,6 +529,8 @@ def build_table( rubble = _rubble_base_component(quantity.components, rubble_base_thickness_m) if rubble is not None: quantity.components.append(rubble) + _append_section_trench(quantity, item, observed, rubble_base_thickness_m) + quantity.unconfirmed = str(item.get("unconfirmed") or "") or missing_height_reason(item) quantities.append(quantity) if use_templates: # 늦게 부름 — 양식 모듈이 이 모듈을 부르므로 맨 위에서 부르면 맞물림. @@ -1550,7 +547,9 @@ def build_table( violations = verify_no_mix_components(quantities) totals: dict[str, dict[str, Any]] = {} - for item in quantities: + # 합은 **금액에 드는 구조물만** — 기본값으로 선 구조물(`unconfirmed`)은 줄에만 보임. + priced = [item for item in quantities if not item.unconfirmed] + for item in priced: for component in item.components: key = f"{component.name}|{component.spec}|{component.unit}" entry = totals.setdefault( @@ -1583,8 +582,12 @@ def build_table( # ⚠ 여기서 새로 만드는 값이 아니라 측점 설계값(`design.ground_type`)을 옮긴 것이다. "ground_type": item.ground_type, "ground_type_basis": item.ground_type_basis, + # 파는 깊이 — 단면으로 터파기를 세운 종류만(옹벽 기초분). 비면 직고 + 기초 깊이. + "trench_depth_m": item.trench_depth_m, # 양식 있음/없음 — 화면이 가림(비면 지금 전개). "library_item": item.library_item, + # 기본값으로 선 까닭 — 차 있으면 금액·자재·토공·운반 합에서 빠짐(`is_unconfirmed`). + "unconfirmed": item.unconfirmed, "notes": item.notes, "components": [ { @@ -1623,7 +626,7 @@ def build_table( COLLECTED_STONE_KEY: round( sum( component.amount - for item in quantities + for item in priced for component in item.components if component.name == "채집석" ), diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Base.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Base.py new file mode 100644 index 00000000..0fbf4bac --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Base.py @@ -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 diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Masonry.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Masonry.py new file mode 100644 index 00000000..83157d30 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Masonry.py @@ -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 diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py index 6fa008f6..b376f997 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py @@ -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 = ( + "⚠ 터파기는 정본대로 면적 × 돌 두께만 — 그 밑 버림·기초잡석 두께는 판 깊이에 안 들어감" + " · 옹벽은 버림·잡석까지 팜 — 바닥막이는 정본이 돌 두께만이라 다름" +) diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_StoneSpec.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_StoneSpec.py new file mode 100644 index 00000000..e22b0719 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_StoneSpec.py @@ -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}" diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py index 78d5b6f5..1009c85a 100644 --- a/B08_Quantity/B08_Quantity_Router_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -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", diff --git a/B08_Quantity/B08_Quantity_Router_Material.py b/B08_Quantity/B08_Quantity_Router_Material.py index acfb3708..5aec260c 100644 --- a/B08_Quantity/B08_Quantity_Router_Material.py +++ b/B08_Quantity/B08_Quantity_Router_Material.py @@ -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( diff --git a/B08_Quantity/B08_Quantity_Router_StmateLibrary.py b/B08_Quantity/B08_Quantity_Router_StmateLibrary.py new file mode 100644 index 00000000..096407cd --- /dev/null +++ b/B08_Quantity/B08_Quantity_Router_StmateLibrary.py @@ -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"], + } + ) diff --git a/B08_Quantity/B08_Quantity_Router_StructureSheet.py b/B08_Quantity/B08_Quantity_Router_StructureSheet.py index a80d9d49..a6b3cbed 100644 --- a/B08_Quantity/B08_Quantity_Router_StructureSheet.py +++ b/B08_Quantity/B08_Quantity_Router_StructureSheet.py @@ -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 ) diff --git a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts index 36c58ed7..0f0c20d1 100644 --- a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts +++ b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts @@ -78,6 +78,8 @@ export interface QuantitySettings { rock_methods?: Record; /** 자재별 관급/사급 — 표 안에서 줄마다 고른 값. */ material_supply?: Record; + /** 자재별 할증률(%) — 표값 이내에서 고른 값만. `null`·없음이면 표값. */ + material_surcharge?: Record; /** 콘크리트 타설 방식. `null`·없음이면 **아직 안 정한 것**이고 화면이 기본값 안내를 띄운다. */ concrete_placing_method?: string | null; /** 표토 두께(m). `null`·없음이면 **안 정한 것**이라 표토제거 줄이 「근거 없음」으로 선다. */ @@ -98,6 +100,11 @@ export interface QuantitySettings { ancillary_counts?: Record; /** 임목축적 등급 — `"소림"`·`"중림"`·`"밀림"`(품셈 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; + /** 처리단가(원/톤) — 수동 · 미확정. */ + 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; + /** 면고르기 갈래 선택지 — 품셈 9-19-1 원문 표 두 벌(서버 한 곳). 화면은 그대로 보임. */ + face_dressing_choices?: { cut: string[]; fill: string[] }; /** 품셈 암종별 범위(안내용). 정의처가 서버라 내려받아 보인다. */ conversion_factor_pumsem_ranges?: PumsemRange[]; /** 도쟈 한계거리 — 지금 값·기본값·근거(서버가 정본). 종무대 20 m 는 규정이라 값만 보인다. */ diff --git a/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts b/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts index da19e5e9..e13fd55b 100644 --- a/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts +++ b/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts @@ -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; + /** 할증률 고른 값 `{이름 규격: % | null}` — 산출 조건 `material_surcharge`. 안 주면 칸 없이 글자만. + * `null` = 표값으로 되돌림. 0 ~ 표값만 받음(서버도 한 번 더 거름). */ + surcharge?: Record; onChange: () => void; } +/** 할증률 칸 — 표값(상한) 안에서만. 범위를 넘으면 칸이 빨갛게 되고 값을 안 받음. */ +function surchargeCell( + row: MaterialRow, + key: string, + picks: Record, + 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 확정). diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index 9125dedf..cbbde4fd 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -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; // 임목축적 등급 — "소림"·"중림"·"밀림". ⚠ 본수가 아니라 축적이다(품셈 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; // 임목파쇄 — **기본 꺼짐**(확정 5차 5번). 켜야 줄이 선다. 근주이식은 칸 자체가 없다. @@ -300,8 +319,15 @@ interface DraftSettings { wood_chipping_volume_m3: number | null; // 노체다짐 — **기본 꺼짐**(9-16-2 [주]⑤ 조건부). 켜면 토공집계에 별도 줄(수량 = 성토량). subgrade_compaction_enabled: boolean; + // 임목폐기물 — 조사값 넷 · 수동 처리단가(미확정) · 분리발주(기본 아님). + tree_waste: Record; + tree_waste_unit_price_krw_per_ton: number | null; + waste_separate_order: boolean; + tree_waste_root_method: string; // 자재별 관급/사급 — 표 안에서 줄마다 고른 값. material_supply: Record; + // 자재별 할증률(%) — 표값 이내에서 고른 값만 · `null` 은 표값. + material_surcharge: Record; // 갈래별 토량환산계수(다짐) — `compacted` 가 `null` 이면 「안 고름」이라 기본값이 선다. conversion_factors: Record; // 도쟈 한계거리(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 { 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) }, 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) }, + 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), }, material_supply: { ...((stored.material_supply ?? {}) as Record) }, + material_surcharge: { + ...((stored.material_surcharge ?? {}) as Record), + }, // ⚠ 저장분에 있는 갈래만 담는다 — 기본값을 복사해 넣으면 「안 고름」이 사라진다. conversion_factors: Object.fromEntries( Object.entries(stored.conversion_factors_override ?? {}).map(([kind, entry]) => [ diff --git a/B08_Quantity/B08_Quantity_UI_Side_BenchCut.ts b/B08_Quantity/B08_Quantity_UI_Side_BenchCut.ts new file mode 100644 index 00000000..9c32bea4 --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Side_BenchCut.ts @@ -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; + }, + }), + ); +} diff --git a/B08_Quantity/B08_Quantity_UI_Side_FaceDressing.ts b/B08_Quantity/B08_Quantity_UI_Side_FaceDressing.ts new file mode 100644 index 00000000..dfed952a --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Side_FaceDressing.ts @@ -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"))); +} diff --git a/B08_Quantity/B08_Quantity_UI_Side_TreeWaste.ts b/B08_Quantity/B08_Quantity_UI_Side_TreeWaste.ts new file mode 100644 index 00000000..36999445 --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Side_TreeWaste.ts @@ -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; + 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"))); +} diff --git a/B08_Quantity/B08_Quantity_UI_StructureFigure.ts b/B08_Quantity/B08_Quantity_UI_StructureFigure.ts new file mode 100644 index 00000000..d2704579 --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_StructureFigure.ts @@ -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; +} diff --git a/B08_Quantity/B08_Quantity_UI_StructureSheet.ts b/B08_Quantity/B08_Quantity_UI_StructureSheet.ts index ebea1de3..22394443 100644 --- a/B08_Quantity/B08_Quantity_UI_StructureSheet.ts +++ b/B08_Quantity/B08_Quantity_UI_StructureSheet.ts @@ -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", "이 제원은 원단위 줄이 서지 않음 — 아래 사유 참고"), diff --git a/B08_Quantity/B08_Quantity_UI_StructureSheet_Library.ts b/B08_Quantity/B08_Quantity_UI_StructureSheet_Library.ts index bc063301..bc2a91a0 100644 --- a/B08_Quantity/B08_Quantity_UI_StructureSheet_Library.ts +++ b/B08_Quantity/B08_Quantity_UI_StructureSheet_Library.ts @@ -8,13 +8,17 @@ * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; +import { buildStmatePanel } from "./B08_Quantity_UI_StructureSheet_Stmate"; const TIER_LABELS: Record = { personal: "개인", company: "회사", program: "기본" }; +/** 항목 종류 배지 — 양식형 = 제원을 바꾸면 수량이 다시 남 · 고정형 = 박힌 수량(명세 13장). */ +const KIND_LABELS: Record = { 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; } diff --git a/B08_Quantity/B08_Quantity_UI_StructureSheet_Stmate.ts b/B08_Quantity/B08_Quantity_UI_StructureSheet_Stmate.ts new file mode 100644 index 00000000..0b1aa968 --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_StructureSheet_Stmate.ts @@ -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(url: string, form: FormData): Promise { + 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(`${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; +} diff --git a/B08_Quantity/B08_Quantity_UI_StructureSheet_UnitPriceEdit.ts b/B08_Quantity/B08_Quantity_UI_StructureSheet_UnitPriceEdit.ts index 66ff8295..f61944a7 100644 --- a/B08_Quantity/B08_Quantity_UI_StructureSheet_UnitPriceEdit.ts +++ b/B08_Quantity/B08_Quantity_UI_StructureSheet_UnitPriceEdit.ts @@ -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); diff --git a/B08_Quantity/B08_Quantity_UI_SummaryGrid.ts b/B08_Quantity/B08_Quantity_UI_SummaryGrid.ts index 7845acce..a3942681 100644 --- a/B08_Quantity/B08_Quantity_UI_SummaryGrid.ts +++ b/B08_Quantity/B08_Quantity_UI_SummaryGrid.ts @@ -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); diff --git a/B08_Quantity/B08_Quantity_Wording.py b/B08_Quantity/B08_Quantity_Wording.py index 86f22eca..673ebde7 100644 --- a/B08_Quantity/B08_Quantity_Wording.py +++ b/B08_Quantity/B08_Quantity_Wording.py @@ -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": ("전면 기울기", "구조물 상세 입력 — 비우면 품셈 표준경사로 자동"), diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities.py b/B09_Estimation/B09_Estimation_BillOfQuantities.py index ff463c61..85079cbb 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities.py @@ -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), diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities_Materials.py b/B09_Estimation/B09_Estimation_BillOfQuantities_Materials.py new file mode 100644 index 00000000..ce8063a6 --- /dev/null +++ b/B09_Estimation/B09_Estimation_BillOfQuantities_Materials.py @@ -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) diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py index 50394419..6cf1df63 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py @@ -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)" diff --git a/B09_Estimation/B09_Estimation_BillRates.py b/B09_Estimation/B09_Estimation_BillRates.py new file mode 100644 index 00000000..8d6f48b0 --- /dev/null +++ b/B09_Estimation/B09_Estimation_BillRates.py @@ -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 diff --git a/B09_Estimation/B09_Estimation_Chisel.py b/B09_Estimation/B09_Estimation_Chisel.py new file mode 100644 index 00000000..9b5d9cca --- /dev/null +++ b/B09_Estimation/B09_Estimation_Chisel.py @@ -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] diff --git a/B09_Estimation/B09_Estimation_Contract.py b/B09_Estimation/B09_Estimation_Contract.py new file mode 100644 index 00000000..01510a0d --- /dev/null +++ b/B09_Estimation/B09_Estimation_Contract.py @@ -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) + ) diff --git a/B09_Estimation/B09_Estimation_CostSheet.py b/B09_Estimation/B09_Estimation_CostSheet.py new file mode 100644 index 00000000..b5299552 --- /dev/null +++ b/B09_Estimation/B09_Estimation_CostSheet.py @@ -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")), + } diff --git a/B09_Estimation/B09_Estimation_CostSheet_Forms.py b/B09_Estimation/B09_Estimation_CostSheet_Forms.py new file mode 100644 index 00000000..fbb770a3 --- /dev/null +++ b/B09_Estimation/B09_Estimation_CostSheet_Forms.py @@ -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}") diff --git a/B09_Estimation/B09_Estimation_Edits.py b/B09_Estimation/B09_Estimation_Edits.py new file mode 100644 index 00000000..17f5b185 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Edits.py @@ -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) diff --git a/B09_Estimation/B09_Estimation_Engine_Cost.py b/B09_Estimation/B09_Estimation_Engine_Cost.py index 2f749d3a..47a46469 100644 --- a/B09_Estimation/B09_Estimation_Engine_Cost.py +++ b/B09_Estimation/B09_Estimation_Engine_Cost.py @@ -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호) — 일반관리비·이윤 밑수에 듦" + ), ) diff --git a/B09_Estimation/B09_Estimation_Engine_Cost_Options.py b/B09_Estimation/B09_Estimation_Engine_Cost_Options.py new file mode 100644 index 00000000..b8bd234d --- /dev/null +++ b/B09_Estimation/B09_Estimation_Engine_Cost_Options.py @@ -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 diff --git a/B09_Estimation/B09_Estimation_Execution.py b/B09_Estimation/B09_Estimation_Execution.py new file mode 100644 index 00000000..be573cd3 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Execution.py @@ -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)}, + } diff --git a/B09_Estimation/B09_Estimation_KnownGaps.py b/B09_Estimation/B09_Estimation_KnownGaps.py index 05efc371..6fc5102a 100644 --- a/B09_Estimation/B09_Estimation_KnownGaps.py +++ b/B09_Estimation/B09_Estimation_KnownGaps.py @@ -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) diff --git a/B09_Estimation/B09_Estimation_Lists.py b/B09_Estimation/B09_Estimation_Lists.py index d2f067ae..ae1de9dd 100644 --- a/B09_Estimation/B09_Estimation_Lists.py +++ b/B09_Estimation/B09_Estimation_Lists.py @@ -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() diff --git a/B09_Estimation/B09_Estimation_Lists_Sources.py b/B09_Estimation/B09_Estimation_Lists_Sources.py index 30f1f11c..fa9d6c8f 100644 --- a/B09_Estimation/B09_Estimation_Lists_Sources.py +++ b/B09_Estimation/B09_Estimation_Lists_Sources.py @@ -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, diff --git a/B09_Estimation/B09_Estimation_MachineCost.py b/B09_Estimation/B09_Estimation_MachineCost.py index ca1615f1..3c1b5576 100644 --- a/B09_Estimation/B09_Estimation_MachineCost.py +++ b/B09_Estimation/B09_Estimation_MachineCost.py @@ -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"], diff --git a/B09_Estimation/B09_Estimation_MachineOperating.py b/B09_Estimation/B09_Estimation_MachineOperating.py index cfaacc55..9cc5f298 100644 --- a/B09_Estimation/B09_Estimation_MachineOperating.py +++ b/B09_Estimation/B09_Estimation_MachineOperating.py @@ -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]) diff --git a/B09_Estimation/B09_Estimation_MachineProductivity.py b/B09_Estimation/B09_Estimation_MachineProductivity.py index 9dd9335a..19be04f2 100644 --- a/B09_Estimation/B09_Estimation_MachineProductivity.py +++ b/B09_Estimation/B09_Estimation_MachineProductivity.py @@ -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) diff --git a/B09_Estimation/B09_Estimation_MachineProductivity_Dozer.py b/B09_Estimation/B09_Estimation_MachineProductivity_Dozer.py index 5c533fa7..6aa9dbd1 100644 --- a/B09_Estimation/B09_Estimation_MachineProductivity_Dozer.py +++ b/B09_Estimation/B09_Estimation_MachineProductivity_Dozer.py @@ -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 diff --git a/B09_Estimation/B09_Estimation_MachineProductivity_Dump.py b/B09_Estimation/B09_Estimation_MachineProductivity_Dump.py new file mode 100644 index 00000000..3b73a5b6 --- /dev/null +++ b/B09_Estimation/B09_Estimation_MachineProductivity_Dump.py @@ -0,0 +1,277 @@ +"""B09 원가계산 — **덤프트럭 운반** 시공능력 (산림사업 표준품셈 10-12 「2. 운반」 · 2026-09-14). + +원문(고시 2025-82호 10-12-1·2·3 「2. 운반」)은 표가 아니라 **채워 넣는 식 서식**이라 공종 마스터에 +안 실렸다. 값은 원문 본문에서 읽어 여기 한 곳에 둔다(식을 데이터로). + + Qt = T / γt × L 덤프 1대 적재토량(㎥, 흐트러진 상태) · T 15ton + n = Qt / (q × K) 적재기계 싸이클 횟수 · q 버켓 0.7㎥ + t1 = ㎝s × n / (60 × Es) 적재 대기(분) · ㎝s 20초 + t2 = (D/V1 + D/V2) × 60 왕복(분) · D 운반거리 km · V1 5 · V2 6 km/hr + ㎝t = t1 + t2 + t3 + t4 + t5 t3 적하 1.1 · t4 대기 0.9 · t5 덮개 0.5 (분) + Q = 60 × Qt × f × E / ㎝t ㎥/hr (자연상태) · f = 1/L + +⚠ **원문이 서로 어긋나는 자리 — 고른 값과 버린 값을 사유에 나란히 둔다**(2026-09-14 브레인 판정). + 사용자가 뒤집을 수 있어야 한다. + ① n 의 Qt — 원문 표기 「10/(0.7×K)」(셋 다 10) ↔ **계산값 T/γt×L**(토사 10.26·암절취 8.44· + 발파암 10.16). 원문이 `q` 한 글자를 적재토량·버켓용량 두 뜻으로 써 10 은 토사 어림수 + 복사로 봄. + ② 발파암 운반 줄에 E 누락 — **0.9**(덤프 작업효율 · 같은 절 토사·암절취 0.9). 빼면 Q 를 못 구함. + ④ Es(적재기계 작업효율 · t1) ≠ E0(적재 식) — **운반 줄 값 그대로**(토사 0.85 · + 암 0.35). +⚠ 운반거리 D 는 원문에 없다 — **설계자 값**(B08 유토곡선·사토장). 없으면 줄을 안 세우고 + 까닭(0 원 금지). +⚠ 적재(「1. 적재」 굴착기 Q1)는 **별도 줄**이다 — 이 식의 t1 은 덤프가 기다리는 시간이지 + 굴착기 품이 아님(흙깎기 9-3-2 ㎝ 20초·135° 는 깎아 옆에 둠 — 22초·180° 싣기와 다른 일). +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from decimal import Decimal +from typing import Any + +from B09_Estimation.B09_Estimation_MachineProductivity import fix2 + +#: 「[주] 장비는 덤프트럭(15ton)을 적용한다」 — 기계 카탈로그 덤프트럭 15. +DUMP_TRUCK_CODE = "0602-0150" +DUMP_PARENT = "FP-10-12" + +_TRUCK_TON = Decimal(15) +_BUCKET_M3 = Decimal("0.7") +_LOADER_CYCLE_SEC = Decimal(20) +_V_LOADED_KMH = Decimal(5) +_V_EMPTY_KMH = Decimal(6) +_T3_UNLOAD, _T4_WAIT, _T5_COVER = Decimal("1.1"), Decimal("0.9"), Decimal("0.5") +_SIXTY = Decimal(60) + + +@dataclass(frozen=True) +class DumpMaterial: + """10-12 절 하나의 재료 값 — 원문 「2. 운반」 줄.""" + + work_item_code: str + label: str + unit_weight: Decimal # γt ton/㎥ + loose_factor: Decimal # L 토량환산계수 + bucket_factor: Decimal # K + loader_efficiency: Decimal # Es + truck_efficiency: Decimal # E + notes: tuple[str, ...] = () + + +_COMMON_NOTE = ( + "n 의 Qt = 계산값 T/γt×L(원문 표기 「10/(0.7×K)」의 10 은 토사 어림수 복사로 봄 — 버림)" +) +_ES_NOTE = "Es = 운반 줄 값(적재 식의 E0 와 다른 기호)" + +DUMP_MATERIALS: dict[str, DumpMaterial] = { + "FP-10-12-01": DumpMaterial( + "FP-10-12-01", "토사", Decimal("1.9"), Decimal("1.3"), Decimal("0.9"), Decimal("0.85"), + Decimal("0.9"), (_COMMON_NOTE, _ES_NOTE), + ), + "FP-10-12-02": DumpMaterial( + "FP-10-12-02", "암절취", Decimal("2.4"), Decimal("1.35"), Decimal("0.55"), Decimal("0.35"), + Decimal("0.9"), (_COMMON_NOTE + " · 원문 10 이면 n 1.18배", _ES_NOTE), + ), + "FP-10-12-03": DumpMaterial( + "FP-10-12-03", "발파암", Decimal("2.4"), Decimal("1.625"), Decimal("0.55"), Decimal("0.35"), + Decimal("0.9"), + (_COMMON_NOTE, _ES_NOTE, "E 원문 누락 — 0.9 적용(같은 절 토사·암절취 · 덤프 작업효율)"), + ), +} # fmt: skip + + +#: 「1. 적재」 — Q1 = 3600 × q0 × K × f × E0 / ㎝ · 굴착기(무한궤도) 0.7㎥ · ㎝ 22초(180°). +#: ⚠ E0 는 운반 식의 Es 와 **다른 기호**다(판정 ④⑥). 토사 본문 「E0=0.75」 는 표(10-12-1 [주]⑤ +#: 「E0 토사 0.60(불량) — 임도」)·10-12-3 [주]③(「토사 0.6」)과 어긋나 **0.60 채택 · 0.75 버림**. +LOADER_CODE = "0201-0070" +_LOADING_CYCLE_SEC = Decimal(22) +_LOADING_E0 = { + "FP-10-12-01": Decimal("0.60"), + "FP-10-12-02": Decimal("0.35"), + "FP-10-12-03": Decimal("0.35"), +} +_LOADING_NOTES = { + "FP-10-12-01": "E0 = 0.60(표 「임도」·10-12-3 [주]③) — 본문 표기 0.75 버림", + "FP-10-12-02": "E0 = 0.35(본문·표 「파쇄암」 같음)", + "FP-10-12-03": "E0 = 0.35(10-12-3 [주]③ 「파쇄암 0.35」)", +} # fmt: skip + + +def loading_output(code: str) -> Decimal: + """적재 Q1 (㎥/hr, 자연상태) — f·Q1 소수 2자리 확정(명세 7장).""" + material = DUMP_MATERIALS[code] + return fix2( + Decimal(3600) + * _BUCKET_M3 + * material.bucket_factor + * fix2(Decimal(1) / material.loose_factor) + * _LOADING_E0[code] + / _LOADING_CYCLE_SEC + ) + + +def loading_title_code(code: str) -> str: + return f"B-{code}#적재" + + +@dataclass(frozen=True) +class DumpHaul: + """덤프 운반 한 벌 — 재료 + 운반거리.""" + + material: DumpMaterial + distance_m: Decimal + + @property + def truck_load_m3(self) -> Decimal: + return _TRUCK_TON / self.material.unit_weight * self.material.loose_factor + + @property + def loader_cycles(self) -> Decimal: + return self.truck_load_m3 / (_BUCKET_M3 * self.material.bucket_factor) + + @property + def cycle_minutes(self) -> Decimal: + wait = _LOADER_CYCLE_SEC * self.loader_cycles / (_SIXTY * self.material.loader_efficiency) + km = self.distance_m / Decimal(1000) + travel = (km / _V_LOADED_KMH + km / _V_EMPTY_KMH) * _SIXTY + return wait + travel + _T3_UNLOAD + _T4_WAIT + _T5_COVER + + @property + def volume_factor(self) -> Decimal: + return fix2(Decimal(1) / self.material.loose_factor) + + @property + def hourly_output(self) -> Decimal: + """Q (㎥/hr) — f 와 Q 는 소수 2자리로 먼저 확정(명세 7장).""" + return fix2( + _SIXTY + * self.truck_load_m3 + * self.volume_factor + * self.material.truck_efficiency + / self.cycle_minutes + ) + + @property + def formula_text(self) -> str: + m = self.material + return ( + f"Qt = 15/{m.unit_weight}×{m.loose_factor} = {self.truck_load_m3:.2f}㎥ · " + f"n = {self.truck_load_m3:.2f}/(0.7×{m.bucket_factor}) = {self.loader_cycles:.2f}회 · " + f"㎝t = {self.cycle_minutes:.2f}분(L={self.distance_m}m) · " + f"Q = 60×{self.truck_load_m3:.2f}×{self.volume_factor}×{m.truck_efficiency}" + f"/{self.cycle_minutes:.2f} = {self.hourly_output}㎥/hr" + ) + + +def dump_title_code(work_item_code: str, distance_m: Decimal) -> str: + """운반거리별 갈래 — 사토장이 여럿이면 거리마다 호표가 갈림(STmate 도 그 모양).""" + return f"B-{work_item_code}#L{distance_m.normalize():f}m" + + +def attach_dump_hauls(build: Any, master: dict[str, Any], distances_m: tuple[Decimal, ...]) -> None: + """거리마다 덤프 운반 일위대가를 세운다 — X(덤프트럭 15ton) → D → B. + + 시간당 사용료가 안 섰으면 세우지 않고 까닭을 남긴다(0 원 일위대가 금지). + """ + from B09_Estimation.B09_Estimation_PriceBook import PriceKind, PriceTitle + + names = { + str(node.get("work_item_code")): str(node.get("name") or "") + for node in master.get("work_items", []) + } + # 적재 — 거리와 무관해 늘 세움(굴착기 0.7㎥ 사용료 층이 있으면). 운반과 한 벌이라 여기 둠. + loader = f"X-{LOADER_CODE}" + for code in DUMP_MATERIALS: + title_code = loading_title_code(code) + if loader not in build.book.titles or title_code in build.book.titles: + continue + output = loading_output(code) + material = DUMP_MATERIALS[code] + label = names.get(code) or material.label + build.book.add_title( + PriceTitle( + code=title_code, + kind=PriceKind.UNIT_PRICE, + name=f"{names.get(DUMP_PARENT) or '덤프운반'} {label} 적재", + spec="굴착기(무한궤도) 0.7㎥", + unit="㎥", + ) + ) + build.book.add_output_detail( + title_code, + loader, + Decimal(1) / output, + f"Q1 = 3600×0.7×{material.bucket_factor}×{fix2(Decimal(1) / material.loose_factor)}" + f"×{_LOADING_E0[code]}/22 = {output}㎥/hr · {_LOADING_NOTES[code]}", + output=output, + ) + build.formula_variants[title_code] = "산림품셈 10-12 「1. 적재」 본문 식" + if not distances_m: + return + hourly = f"X-{DUMP_TRUCK_CODE}" + for code, material in DUMP_MATERIALS.items(): + if hourly not in build.book.titles: + build.component_gaps[code] = "덤프트럭(15ton) 시간당 사용료가 아직 안 섰습니다" + continue + for distance in distances_m: + haul = DumpHaul(material, distance) + title_code = dump_title_code(code, distance) + if title_code in build.book.titles: + continue + build.book.add_title( + PriceTitle( + code=title_code, + kind=PriceKind.UNIT_PRICE, + name=f"{names.get(DUMP_PARENT) or '덤프운반'} " + f"{names.get(code) or material.label}", + spec=f"L={distance.normalize():f}m", + unit="㎥", + ) + ) + build.book.add_output_detail( + title_code, + hourly, + Decimal(1) / haul.hourly_output, + haul.formula_text + " · " + " · ".join(material.notes), + output=haul.hourly_output, + ) + build.formula_variants[title_code] = "산림품셈 10-12 「2. 운반」 본문 식 · 운반거리" + + +def dump_haul_distances(payload: dict[str, Any]) -> tuple[str, ...]: + """B08 인계에서 덤프 운반 거리(m) 갈래 — 거리가 없는 줄은 안 셈(그 줄은 「운반거리 미입력」).""" + found = { + str(Decimal(str(row["haul_distance_m"]))) + for row in payload.get("work_items") or [] + if str(row.get("work_item_code") or "").startswith(DUMP_PARENT) + and row.get("haul_equipment") == "dump_truck" + and row.get("haul_distance_m") not in (None, "") + } + return tuple(sorted(found, key=Decimal)) + + +_RE_DUMP_CODE = re.compile(rf"^[BD]-{DUMP_PARENT}-0[1-3]#L(\d+(?:\.\d+)?)m$") + +#: 인계 「덤프 적재」 짝 줄의 수단 표지 — 운반 줄(`dump_truck`)과 갈라 잎 `#적재` 로 감. +LOADING_EQUIPMENT = "dump_loading" + + +def dump_distance_from_code(code: str) -> tuple[str, ...]: + """호표 한 장을 여는 쪽 — 코드 `#L164.23m` 에서 거리를 되읽음(인계를 다시 안 셈).""" + found = _RE_DUMP_CODE.match(code) + return (found.group(1),) if found else () + + +def dump_child_for(ground: str | None, edition: str) -> str | None: + """인계 갈래(토사·리핑암·발파암) → 10-12 잎. 모르는 갈래는 `None` — 가까운 것을 안 고름.""" + from common_util.common_util_aliases import alias_target, load_aliases + + wanted = str(ground or "").strip() + if not wanted: + return None + alias = alias_target(load_aliases("variant"), wanted, DUMP_PARENT, edition) or wanted + return next( + (code for code, material in DUMP_MATERIALS.items() if material.label == alias), None + ) diff --git a/B09_Estimation/B09_Estimation_MachineProductivity_Reference.py b/B09_Estimation/B09_Estimation_MachineProductivity_Reference.py index 91442755..311bb893 100644 --- a/B09_Estimation/B09_Estimation_MachineProductivity_Reference.py +++ b/B09_Estimation/B09_Estimation_MachineProductivity_Reference.py @@ -17,7 +17,8 @@ ⚠ **못 따라가는 참조는 따라간 척하지 않는다.** · **자기 자신을 가리키는 것** — 9-13-14 가 「육상 발파암(1~2m)와 동일」이라 적었는데 - 그 절이 곧 육상 발파암(1~2m)이다(원문 오기로 보이나 **고쳐 읽지 않는다**). + 그 절이 곧 육상 발파암(1~2m)이다. ⭐ 2026-09-14 브레인 승인으로 **그 두 절만** 고쳐 읽음 + (`READ_AS` — 고른 값·버린 값을 근거 줄에 나란히). 나머지 자기 참조는 여전히 빈 채로. · **가리키는 절을 못 찾는 것 · 그 절도 계수가 없는 것.** 이 셋은 사유를 남기고 **빈 채로 둔다.** """ @@ -35,6 +36,21 @@ _NAMED = re.compile(r"^(?P.+?)\s*(?:와|과)\s*동일$") #: 「육상과동일」 — 이름이 아니라 **한 낱말만 바꾸라**는 지시다(용수 → 육상). _SWAP_WORDS = (("용수", "육상"),) +#: 원문 참조가 풀 수 없게 적힌 자리를 **사람이 판정해** 고쳐 읽음 — 공종 → (읽을 이름, 사유). +#: ⚠ 넓히지 말 것. 판정마다 근거(같은 절 형제들의 표기)를 사유에 적고 브레인·사용자 승인을 받음. +READ_AS: dict[str, tuple[str, str]] = { + "FP-09-13-14": ( + "육상 발파암(0~1m)", + "원문 표기 「육상 발파암(1~2m)와 동일」(고시 2025-82 5189줄)은 자기 참조라 풀 수 없어" + " 0~1m(9-13-13)로 읽음 — 같은 절 암절취(1~2m·2~3m)·용수 발파암이 모두 「(0~1m)와 동일」", + ), + "FP-09-13-15": ( + "육상 발파암(0~1m)", + "원문 표기 「육상 발파암(1~2m)와 동일」(고시 2025-82 5201줄)이 가리키는 9-13-14 가 자기" + " 참조라 끝이 없어 0~1m(9-13-13)로 읽음 — 같은 절 형제들이 모두 「(0~1m)와 동일」", + ), +} + #: 이 이름들만 계수로 본다. 참조가 가리키는 것도 결국 이 넷이다. _FACTOR_HEADS = { "k": "K", @@ -156,6 +172,9 @@ def reference_factor_values( if found is None: return own, [], "" target_name, raw_text = found + read_as = READ_AS.get(code) + if read_as: + target_name = read_as[0] matches = [m for m in by_name.get(_normalize_name(target_name), []) if m != code] if not matches: return own, [], f"「{raw_text}」가 가리키는 절을 못 찾았습니다" @@ -172,6 +191,8 @@ def reference_factor_values( if missing: return own, [], f"「{raw_text}」를 따라갔으나 계수가 없습니다 — {', '.join(missing)}" step = f"「{raw_text}」 → {nodes[target].get('name', target)}" + if read_as: + step += f" ⚠ {read_as[1]}" return merged, [step, *path], "" for code, node in nodes.items(): diff --git a/B09_Estimation/B09_Estimation_MaterialPrices.py b/B09_Estimation/B09_Estimation_MaterialPrices.py new file mode 100644 index 00000000..5cfdf16c --- /dev/null +++ b/B09_Estimation/B09_Estimation_MaterialPrices.py @@ -0,0 +1,234 @@ +"""B09 원가계산 — **자재 수동 단가** 한 벌 (PLAN 1장 Ⓐ · 2026-09-14 브레인 판정). + + 저장 자리 산출 조건 `estimation.material_prices` + {키: {price_krw, source, entered_at, name, spec, unit}} + 키 자원 코드가 있으면 **코드**(`AR-M-…`) · 코드 없는 자재만 「이름 규격」 + (명세 2장 「잇기는 코드로」 · 11장 「규격은 조인 키」) + ⚠ 이름·규격·단위는 **보이기용** — 맞추는 데 안 씀. 이름이 고쳐져도 단가가 안 끊김 + 얹는 자리 조립 때 자재 제목(M)을 세움 — 6번 슬롯 = 수동 단가 · 쪽수 칸 = 출처 + → 자원 축 자재 줄이 그 제목에 붙어 일위대가 재료비가 섬 + 넣은 날 값·출처가 같으면 다시 저장해도 안 바뀜(구조물도 수동 단가 `save_manual` 과 같은 꼴) + 미확정 수동 단가가 닿은 내역 줄마다 「미확정 N건」(구조물도 수동 단가·폐기물과 같은 통로) + +⚠ 값이 없거나 0 이하인 칸은 받지 않음 — 0 원으로 채우면 「단가 없음」이 금액 0 으로 숨음. +⚠ 코드 없는 「이름 규격」 줄은 자재총괄 몫(다음 단계) — 조립에는 코드 줄만 얹음. + 이름 줄에 나중에 코드가 붙으면 코드 키로 옮겨 붙일 자리 — 아직 안 만듦(브레인 판정). +""" + +from __future__ import annotations + +import json +import os +import re +from decimal import Decimal, InvalidOperation +from functools import lru_cache +from typing import Any + +from B09_Estimation.B09_Estimation_PriceBook import ( + PRICE_SLOT_COUNT, + PriceBook, + PriceKind, + PriceTitle, +) + +MATERIAL_PRICES_KEY = "material_prices" +#: 조립에 얹는 키 — 자원 목록 자재 코드(명세 §2 ③ 모양). +_RE_MATERIAL_CODE = re.compile(r"^AR-M-[0-9a-f]{8}$") +#: 출처를 안 적었을 때 쪽수 칸 글. +MANUAL_SOURCE = "수동 입력" +_TEXT_FIELDS = ("source", "name", "spec", "unit") + + +class MaterialPriceError(ValueError): + """받을 수 없는 단가 — 저장하지 않고 까닭을 돌려줌.""" + + +def _price(value: Any) -> Decimal | None: + try: + price = Decimal(str(value).replace(",", "").strip()) + except (InvalidOperation, ValueError): + return None + return price if price.is_finite() and price > 0 else None + + +def is_code_key(key: str) -> bool: + """조립에 얹는 코드 키인가 — 아니면 「이름 규격」 키.""" + return bool(_RE_MATERIAL_CODE.match(key)) + + +def normalize(raw: Any) -> dict[str, dict[str, str]]: + """저장본을 받을 수 있는 줄만 — 값이 없거나 0 이하면 버림.""" + prices: dict[str, dict[str, str]] = {} + if not isinstance(raw, dict): + return prices + for key, entry in raw.items(): + price = _price(entry.get("price_krw")) if isinstance(entry, dict) else None + if not str(key).strip() or price is None: + continue + prices[str(key)] = { + "price_krw": str(price), + "entered_at": str(entry.get("entered_at") or ""), + **{field: str(entry.get(field) or "") for field in _TEXT_FIELDS}, + } + return prices + + +def build_key(raw: Any) -> tuple[tuple[str, str, str], ...]: + """조립 캐시 키 — 코드 키만 (키, 단가, 출처). 같은 값이면 같은 벌.""" + return tuple( + sorted( + (key, entry["price_krw"], entry["source"]) + for key, entry in normalize(raw).items() + if is_code_key(key) + ) + ) + + +def merge(stored: Any, changes: list[dict[str, Any]], today: str) -> dict[str, dict[str, str]]: + """바꿀 것 여럿을 한 번에 — `price_krw` 가 비면 그 줄을 지움(단가 없음으로 돌아감).""" + prices = normalize(stored) + for change in changes: + key = str(change.get("key") or "").strip() + if not key: + raise MaterialPriceError("자재 키가 비었습니다") + raw_price = change.get("price_krw") + if raw_price is None or str(raw_price).strip() == "": + prices.pop(key, None) + continue + price = _price(raw_price) + if price is None: + raise MaterialPriceError(f"단가는 0 보다 큰 수여야 합니다: {raw_price}") + old = prices.get(key) or {} + source = str(change.get("source") or "").strip() + same = old.get("price_krw") == str(price) and old.get("source") == source + prices[key] = { + "price_krw": str(price), + "entered_at": old["entered_at"] if same and old.get("entered_at") else today, + "source": source, + **{ + field: str(change.get(field) or old.get(field) or "") + for field in ("name", "spec", "unit") + }, + } + return prices + + +@lru_cache(maxsize=1) +def material_catalog_rows() -> dict[str, dict[str, str]]: + """자원 목록(`AR-M-`) 코드 → 이름·규격·단위 — 제목을 세우고 화면에 보일 글.""" + from B09_Estimation.B09_Estimation_ResourceAxis_Join import EXT_CATALOG_FILE + + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + path = os.path.join(root, "resources", "data_resource_catalog", EXT_CATALOG_FILE) + if not os.path.isfile(path): + return {} + with open(path, encoding="utf-8") as handle: + entries = json.load(handle).get("entries") or [] + return { + str(row["code"]): { + "name": str(row.get("name") or ""), + "spec": str(row.get("spec") or ""), + "unit": str(row.get("unit") or ""), + } + for row in entries + if is_code_key(str(row.get("code") or "")) + } + + +def add_material_titles( + book: PriceBook, prices: tuple[tuple[str, str, str], ...] +) -> dict[str, str]: + """코드 키 수동 단가로 자재 제목(M)을 세움 — 돌려주는 값 = 코드 → 출처(미확정 셈에 씀). + + 자원 목록에 없는 코드는 안 세움 — 화면 목록이 「단가표에서 사라진 코드」로 드러냄(`listing`). + """ + catalog = material_catalog_rows() + manual: dict[str, str] = {} + for code, price, source in prices: + row = catalog.get(code) + if row is None or code in book.titles: + continue + slots: list[Decimal | None] = [None] * PRICE_SLOT_COUNT + pages: list[str | None] = [None] * PRICE_SLOT_COUNT + slots[-1] = Decimal(price) + pages[-1] = source or MANUAL_SOURCE + book.add_title( + PriceTitle( + code=code, + kind=PriceKind.MATERIAL, + name=row["name"], + spec=row["spec"], + unit=row["unit"], + slots=slots, + slot_pages=pages, + ) + ) + manual[code] = source or MANUAL_SOURCE + return manual + + +def manual_count(book: PriceBook, code: str | None, manual: dict[str, str]) -> int: + """그 단가가 밟는 **수동 단가 자재 수**(같은 자재는 한 번) — 내역 줄 「미확정 N건」.""" + if not code or not manual: + return 0 + found: set[str] = set() + seen: set[str] = set() + stack = [code] + while stack: + current = stack.pop() + if current in seen: + continue + seen.add(current) + if current in manual: + found.add(current) + stack.extend(detail.ref_code for detail in book.details.get(current, [])) + return len(found) + + +def listing( + build: Any, stored: Any, materials: list[dict[str, Any]] | None = None +) -> list[dict[str, Any]]: + """화면 줄 — 자원 축이 쓰는 자재(코드) + 자재총괄 자재(「이름 규격」) + 저장만 남은 줄. + + `materials` — B08 인계 `materials`(자재총괄). 사급 줄만 단가 칸이 섬(관급은 도급 금액 밖). + ⚠ 사라진 줄도 조용히 안 버림 — 「사라진 자재」로 보이고 사용자가 지움. + """ + prices = normalize(stored) + catalog = material_catalog_rows() + uses: dict[str, list[str]] = getattr(build, "material_uses", {}) or {} + rows: list[dict[str, Any]] = [] + + def add(key: str, shown: dict[str, Any], **extra: Any) -> None: + entry = prices.get(key) or {} + rows.append( + { + "key": key, + **{ + field: shown.get(field) or entry.get(field, "") + for field in ("name", "spec", "unit") + }, + "price_krw": entry.get("price_krw"), + "source": entry.get("source", ""), + "entered_at": entry.get("entered_at", ""), + "missing": False, + **extra, + } + ) + + for code in sorted(uses, key=lambda c: (catalog.get(c, {}).get("name", ""), c)): + add(code, catalog.get(code, {}), work_items=sorted(uses[code]), origin="unit_price") + for material in materials or []: + key = f"{material.get('material_name', '')} {material.get('spec') or ''}".strip() + add( + key, + {**material, "name": material.get("material_name")}, + work_items=list(material.get("source_structure") or []), + origin="material_sheet", + supply_type=material.get("supply_type") or "", + quantity=str(material.get("total_amount")), + ) + listed = {row["key"] for row in rows} + for key, entry in sorted(prices.items()): + if key not in listed: + rows.append({"key": key, **entry, "work_items": [], "missing": True}) + return rows diff --git a/B09_Estimation/B09_Estimation_MaterialSheet.py b/B09_Estimation/B09_Estimation_MaterialSheet.py index 135b36ff..53d5f2d6 100644 --- a/B09_Estimation/B09_Estimation_MaterialSheet.py +++ b/B09_Estimation/B09_Estimation_MaterialSheet.py @@ -58,6 +58,9 @@ class MaterialSheetRow: surcharge_pct: Decimal | None = None source_structure: tuple[str, ...] = () note: str = "" + #: 「자재 단가」 탭 키(이름 규격) · 수동 단가로 섰나(= 미확정). + key: str = "" + manual: bool = False def as_dict(self) -> dict[str, Any]: def money(value: Decimal | None) -> str | None: @@ -75,6 +78,8 @@ class MaterialSheetRow: "surcharge_pct": money(self.surcharge_pct), "source_structure": list(self.source_structure), "note": self.note, + "key": self.key, + "manual": self.manual, } @@ -119,8 +124,14 @@ def build_material_sheet( *, surcharge_status: str = SURCHARGE_RATE_UNAVAILABLE, catalog: MaterialCatalog | None = None, + manual: dict[str, dict[str, str]] | None = None, ) -> MaterialSheet: - """자재 목록에 단가를 붙인다. 못 붙이면 **금액을 비우고 사유를 남긴다**.""" + """자재 목록에 단가를 붙인다. 못 붙이면 **금액을 비우고 사유를 남긴다**. + + `manual` — 「자재 단가」 탭 수동 단가(`MaterialPrices.normalize`, 키 「이름 규격」). + ⚠ **사급 단가는 수동 단가만**(2026-09-14 PLAN 1장 Ⓐ-2) — 나라장터는 관급 단가라 사급에 쓰면 + 도급 재료비가 관급 값으로 조용히 섬. 관급 줄은 종전대로 나라장터(총원가 밖). + """ book = catalog or load_material_catalog() sheet = MaterialSheet() @@ -141,6 +152,7 @@ def build_material_sheet( surcharge_pct=getattr(material, "surcharge_pct", None), source_structure=tuple(getattr(material, "source_structure", ()) or ()), ) + row.key = f"{row.name} {row.spec}".strip() if row.supply_type == SUPPLY_UNKNOWN: # ⚠ 어느 쪽에도 안 넣는다 — 넣으면 총액이 틀리고 나중에 못 가린다. @@ -151,19 +163,33 @@ def build_material_sheet( ) continue - found = book.resolve(row.name, row.spec) - if found is None: + entry = (manual or {}).get(row.key) if row.supply_type == SUPPLY_CONTRACTOR else None + found = book.resolve(row.name, row.spec) if row.supply_type == SUPPLY_OWNER else None + price = Decimal(entry["price_krw"]) if entry else found.price_krw if found else None + if price is None: row.note = ( - "자재 단가가 없습니다 — 유료 물가지 미결(No.18). " - "6번 슬롯(적용 단가) 수동 입력 대기." + "사급 자재 단가 없음 — 「자재 단가」 탭에서 수동 입력(유료 물가지 미결 No.18)" + if row.supply_type == SUPPLY_CONTRACTOR + else "관급 자재 단가 없음 — 나라장터 목록에 이 품목이 없음" ) sheet.missing.append( {"name": row.name, "unit": row.unit, "reason": "자재 단가 없음(미결 No.18)"} ) else: - row.unit_price_krw = found.price_krw + row.unit_price_krw = price # 자재대 줄도 **내역서 본체와 같은 절사** 자리다. - row.amount_krw = round_at(found.price_krw * row.total_amount, OutputPlace.BOQ_ROW) + row.amount_krw = round_at(price * row.total_amount, OutputPlace.BOQ_ROW) + if entry: + row.manual = True + row.note = " · ".join( + part + for part in ( + "⚠ 수동 단가(미확정)", + entry.get("source"), + entry.get("entered_at"), + ) + if part + ) if row.supply_type == SUPPLY_OWNER: sheet.owner_rows.append(row) diff --git a/B09_Estimation/B09_Estimation_ParentSteps.py b/B09_Estimation/B09_Estimation_ParentSteps.py index ab79c7df..934f5dc0 100644 --- a/B09_Estimation/B09_Estimation_ParentSteps.py +++ b/B09_Estimation/B09_Estimation_ParentSteps.py @@ -106,12 +106,22 @@ def _add_rock_leaf(build: Any, node: dict[str, Any]) -> None: + (f" (기계 단가 층 없음: {', '.join(missing)})" if missing else "") ) return - variants = [(rock, q, f"작업능력 Q = {q} ㎥/hr ({rock})") for rock, q, _ in rows] + from B09_Estimation.B09_Estimation_Chisel import ( + CHISEL_AVERAGE_NOT_GIVEN, + CHISEL_MISSING, + attach_chisel, + ) + + variants = [(rock, q, f"작업능력 Q = {q} ㎥/hr ({rock})", chisel) for rock, q, chisel in rows] if code in AVERAGE_BASIS: # 평균 Q 도 **소수 2자리로 확정**한 뒤 나눔(명세 7장) — (5.0+3.4+2.6)/3 = 3.67. + # ⚠ 치즐 평균은 원문에 없어 안 붙임(None) — 지어내지 않고 사유로 남김. average = fix2(sum((q for _, q, _ in rows), Decimal(0)) / Decimal(len(rows))) - variants.append((AVERAGE_VARIANT, average, f"Q = {average} ㎥/hr — {AVERAGE_BASIS[code]}")) - for variant, capacity, note in variants: + variants.append( + (AVERAGE_VARIANT, average, f"Q = {average} ㎥/hr — {AVERAGE_BASIS[code]}", None) + ) + attached = False + for variant, capacity, note, chisel in variants: title_code = f"B-{code}#{variant}" build.book.add_title( PriceTitle( @@ -125,11 +135,29 @@ def _add_rock_leaf(build: Any, node: dict[str, Any]) -> None: for machine_code, _name in machines: # Q 로 선 장비 줄 — D(단가산출)에 달고 B 는 D 를 1 로 부름(층 차례 X → D → B). build.book.add_output_detail( - title_code, f"X-{machine_code}", Decimal(1) / capacity, f"{note} · {source}" + title_code, + f"X-{machine_code}", + Decimal(1) / capacity, + f"{note} · {source}", + output=capacity, ) + # 치즐 — 암질마다 표의 본/hr ÷ Q(수동 단가가 들어와 치즐 제목이 섰을 때만). + attached = attach_chisel(build.book, title_code, chisel, capacity) or attached build.variants.setdefault(code, []).append(variant) - # 표 줄은 다 읽었다 — 남는 것은 치즐뿐(자재 카탈로그가 없어 금액에 안 붙는 알려진 미결). - build.unattached[code] = ["치즐소모량(본/hr) — 자재 단가 층 없음"] if rows[0][2] else [] + # 표 줄은 다 읽었다 — 남는 것은 치즐뿐. 단가가 없으면 못 붙은 줄, 붙었으면 평균 갈래 사유만. + if rows[0][2]: + # 암질 표는 치즐이 열이라 「치즐소모」 줄로 안 잡힘 — 「자재 단가」 탭 쓰이는 곳에 올림. + from B09_Estimation.B09_Estimation_Chisel import CHISEL_CODE + + uses = build.material_uses.setdefault(CHISEL_CODE, []) + if code not in uses: + uses.append(code) + if not rows[0][2]: + build.unattached[code] = [] + elif not attached: + build.unattached[code] = [CHISEL_MISSING] + else: + build.unattached[code] = [CHISEL_AVERAGE_NOT_GIVEN] if code in AVERAGE_BASIS else [] def _step_titles(build: Any, step: str) -> dict[str, str]: diff --git a/B09_Estimation/B09_Estimation_PriceBasis.py b/B09_Estimation/B09_Estimation_PriceBasis.py index e2441423..546ce507 100644 --- a/B09_Estimation/B09_Estimation_PriceBasis.py +++ b/B09_Estimation/B09_Estimation_PriceBasis.py @@ -24,7 +24,7 @@ from dataclasses import dataclass, field from decimal import Decimal from typing import Any -from B09_Estimation.B09_Estimation_PriceBook import PriceBook, PriceKind +from B09_Estimation.B09_Estimation_PriceBook import Money3, PriceBook, PriceKind from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, cached_build @@ -43,6 +43,8 @@ class PriceBasisEntry: ref_code: str #: 이 산출서를 부르는 내역 일위대가 전부(갈래·단계 합산 부모 포함). unit_price_codes: list[str] = field(default_factory=list) + #: 성분 금액 — 목록표 「노무비·재료비·경비」 칸(머리 성분 원 미만 절사 값). + money: Money3 = field(default_factory=Money3) @property def label(self) -> str: @@ -59,6 +61,10 @@ class PriceBasisEntry: "unit": self.unit, "unit_price_krw": str(self.unit_price_krw), "ref_code": self.ref_code, + "unit_price_codes": list(self.unit_price_codes), + "material_krw": str(self.money.material), + "labor_krw": str(self.money.labor), + "expense_krw": str(self.money.expense), } @@ -116,14 +122,16 @@ def build_price_basis( entry = numbered.get(basis) if entry is None: title = book.title(basis) + money = book.resolve(basis) entry = PriceBasisEntry( number=len(sheet.entries) + 1, code=basis, name=title.name, spec=title.spec, unit=title.unit, - unit_price_krw=round_at(book.resolve(basis).total, OutputPlace.UNIT_PRICE_ROW), + unit_price_krw=round_at(money.total, OutputPlace.UNIT_PRICE_ROW), ref_code=code, + money=money, ) sheet.entries.append(entry) numbered[basis] = entry diff --git a/B09_Estimation/B09_Estimation_PriceBook.py b/B09_Estimation/B09_Estimation_PriceBook.py index cd245c59..b125dd8e 100644 --- a/B09_Estimation/B09_Estimation_PriceBook.py +++ b/B09_Estimation/B09_Estimation_PriceBook.py @@ -182,6 +182,9 @@ class PriceDetail: #: ⚠ 원문이 「재료비의 **할증수량 제외**」라 밑수가 **할증 전** 값이어야 하는데, 일위대가 #: 층의 재료비가 곧 할증 전 값이다(할증은 자재총괄에서 한 번만 — PLAN 8-7 ㉠). percent_of_material: Decimal | None = None + #: 시공능력 **Q**(소수 2자리 확정 값) — Q 로 선 장비 줄(D)에만. 수량 = 몫 ÷ Q 라 Q 식을 고치면 + #: 새 수량 = 수량 × 옛 Q ÷ 새 Q(PLAN 12장 2차 ③ · `B09_Estimation_Edits`). + output: Decimal | None = None @dataclass @@ -202,7 +205,12 @@ class PriceBook: self.details.setdefault(detail.parent_code, []).append(detail) def add_output_detail( - self, parent_code: str, ref_code: str, quantity: Decimal, note: str = "" + self, + parent_code: str, + ref_code: str, + quantity: Decimal, + note: str = "", + output: Decimal | None = None, ) -> str: """**시공능력 Q** 로 선 장비 줄 — B 에 바로 안 달고 **D(단가산출)** 에 달고 B 는 D 를 1 로 부름. @@ -222,7 +230,7 @@ class PriceBook: ) ) self.add_detail(PriceDetail(parent_code, basis_code, Decimal(1), note="단가산출(Q)")) - self.add_detail(PriceDetail(basis_code, ref_code, quantity, note=note)) + self.add_detail(PriceDetail(basis_code, ref_code, quantity, note=note, output=output)) return basis_code def title(self, code: str) -> PriceTitle: diff --git a/B09_Estimation/B09_Estimation_Progress.py b/B09_Estimation/B09_Estimation_Progress.py new file mode 100644 index 00000000..d6c8ad55 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Progress.py @@ -0,0 +1,463 @@ +"""B09 기성 단계 — 계약내역 → 기성내역 · 기성 제잡비 계산서 (PLAN 12장 · 2026-09-14 배정). + +근거: STmate 분석 `27_계약_실행_기성_단계.md` §3 (`wM_KanJub_K` 「기성 제잡비 계산서」) · + `22_UI_폼_분석.md` §3.5 · `35_형식과_단계의_공통과_차이.md` §3 · + 원자료 `ui_form_catalog.txt` 133~137행. + · ⭐ 간접비를 설계처럼 줄마다 밑수 × 요율로 다시 셈하지 **않음** — + 「금회직접공사비 × 계약제잡비율」 + · 계약(도급액) · 전회 · 금회 · 누계 네 칸과 각 비율(전율·금율·누율) + · 부가세 (0) 직접입력 · (1) 공급가액의 10% · (2) 재료비의 10% · (3) 재료비+산출경비의 10% + +⚠ **계약을 안 건드린다** — 계약내역 줄(`contract_bill` 결과)과 계약 원가계산서(같은 엔진에 계약 + 직접비)를 **읽기만** 해서 파생한다. +⚠ **값이 맞다가 아니라 구조가 선다까지** — 기성 표본 0건(27번 §9 · 35번 「회차별 값과 + `QTY1_B/N/O/H` 뜻 미확인」). 그 네 칸은 쓰지 않고, 아래는 **구조로 읽은 것**이라 확인 대기: + ① 계약잡비율 = 계약 원가계산서 그 줄 금액 ÷ 계약 직접공사비(격자 `계약잡비율` 열이 줄마다 있음) + ② 회차마다 금회 금액을 원 미만 절사해 쌓고 전회 = 앞 회차 금회의 합 · 누계 = 전회 + 금회 + ③ 제잡비 줄 = 간접노무비 ~ 부가세 직전(11번 §10) — 간접재료비·관급·분리발주 폐기물은 밖 + 원문 대조: 예정가격작성기준 제39조②(표준시장단가 장) 간접공사비 「1. 간접노무비」~「10.」 — + 간접재료비 없음 · 제17조(원가계산 장)는 간접재료비를 재료비 안에 둠. 원가계산 장에는 + 「간접공사비」 묶음이 없어 기성 범위를 직접 정한 글은 아님 — 간접재료비 자리는 두 조문이 + 한 방향이라 ③ 확인 대기 닫음(2026-09-14 브레인 판정). + ①′ 계약잡비율 직접입력(사유 필수)이 역산값을 이김 — 계약서에 제잡비율이 명시됨(브레인 판정). + ④ 사정 — 「기성내역서(사정)」 열 이름뿐이라 칸만 받고 계산에 안 씀. +""" + +from __future__ import annotations + +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 +from B09_Estimation.B09_Estimation_CostSheet import EXPENSE_ORDER +from B09_Estimation.B09_Estimation_Engine_Cost_Options import ( + CUT_UNITS_KRW, + cut_gap, + profit_cut, + vat_base, +) +from B09_Estimation.B09_Estimation_PriceBook import Money3 + +_ZERO = Decimal(0) +_HUNDRED = Decimal(100) + +#: 저장 자리 — `estimation` 구획 안 한 칸. +SETTINGS_KEY = "progress" + +#: 부가세 — `cmBx_Buga` 표기 그대로. 키는 설계 원가계산서 `VAT_MODES` 와 같은 뜻이면 같은 키. +VAT_CHOICES: tuple[tuple[str, str], ...] = ( + ("manual", "(0) 직접입력"), + ("supply", "(1) 공급가액의 10%"), + ("material", "(2) 재료비의 10%"), + ("forest_coop", "(3) 재료비+산출경비의 10%"), +) +#: 화면 표기 「10%」 그대로. +_VAT_PERCENT = Decimal(10) + +#: 제잡비 줄 — 간접노무비 ~ 부가세 직전. 설계 원가계산서 서식 차례. +_ITEM_ORDER: tuple[tuple[str, str], ...] = ( + ("indirect_labor_cost", "간접노무비"), + *EXPENSE_ORDER, + ("general_overhead", "일반관리비"), + ("profit", "이윤"), +) + +_COLUMNS = ("previous", "current", "cumulative") + +#: 절사 — `cmBx_JeolSa`(공급가액)·`cmBx_Tot_Jeol`(총공사비) 9택. 첫 칸(빈 값)은 각각 +#: 「총공사비에서 조정」·「이윤금액 직접입력」. 떨어지는 몫은 설계 원가계산서처럼 **이윤에서** 뺌. +CUT_CHOICES: tuple[str, ...] = ("", *(str(unit) for unit in CUT_UNITS_KRW)) +_CUT_MAX_PASSES = 3 + + +def _number(value: Any) -> Decimal | None: + try: + number = Decimal(str(value)) + except (InvalidOperation, ValueError): + return None + return number if number.is_finite() and number >= 0 else None + + +def _floor(value: Decimal) -> Decimal: + return value.to_integral_value(rounding=ROUND_FLOOR) + + +def _pct(part: Decimal, whole: Decimal) -> str | None: + return str((part / whole * _HUNDRED).quantize(Decimal("0.001"))) if whole else None + + +def _amounts(raw: dict[str, Any] | None, label: str, errors: list[str]) -> dict[str, str]: + """{줄: 0 이상 수} — 빈 칸은 뺌 · 틀린 칸은 까닭.""" + kept: dict[str, str] = {} + for key, value in (raw or {}).items(): + if value in (None, ""): + continue + number = _number(value) + if number is None: + errors.append(f"{label} {key} 값이 0 이상 수가 아님 — {value}") + continue + kept[str(key)] = str(number) + return kept + + +def _optional(value: Any, label: str, errors: list[str]) -> str: + if value in (None, ""): + return "" + if _number(value) is None: + errors.append(f"{label}이 0 이상 수가 아님 — {value}") + return "" + return str(_number(value)) + + +def clean_settings(values: dict[str, Any]) -> tuple[dict[str, Any], list[str]]: + """회차 목록 · 계약잡비율 직접입력 저장값과 거른 까닭.""" + errors: list[str] = [] + rounds: list[dict[str, Any]] = [] + for number, entry in enumerate(values.get("rounds") or [], start=1): + entry = entry or {} + mode = str(entry.get("vat_mode") or "supply") + if mode not in dict(VAT_CHOICES): + errors.append(f"{number}회 부가세 방식을 모름 — {mode}") + mode = "supply" + manual = _optional(entry.get("vat_manual_krw"), f"{number}회 부가세 직접입력 금액", errors) + if mode == "manual" and manual == "": + errors.append(f"{number}회 부가세 직접입력 금액이 비었음") + cuts = {} + for key in ("supply_cut_krw", "total_cut_krw"): + unit = str(entry.get(key) or "") + cuts[key] = unit if unit in CUT_CHOICES else "" + rounds.append( + { + "quantities": _amounts(entry.get("quantities"), f"{number}회 기성수량", errors), + "vat_mode": mode, + "vat_manual_krw": manual, + **cuts, + "profit_manual_krw": _optional( + entry.get("profit_manual_krw"), f"{number}회 이윤금액 직접입력", errors + ), + "assessed": _amounts(entry.get("assessed"), f"{number}회 사정", errors), + } + ) + overrides: dict[str, dict[str, str]] = {} + for key, entry in (values.get("rate_overrides") or {}).items(): + entry = entry or {} + rate = _optional(entry.get("rate_pct"), f"{key} 계약잡비율", errors) + reason = str(entry.get("reason") or "").strip() + if rate == "": + continue + if not reason: + errors.append(f"{key} 계약잡비율을 고친 사유가 비었음") + continue + overrides[str(key)] = {"rate_pct": rate, "reason": reason} + return {"rounds": rounds, "rate_overrides": overrides}, errors + + +def _leaf(row: dict[str, Any]) -> bool: + return ( + not row.get("is_group") + and row.get("in_bill", True) + and row.get("contract_amount_krw") is not None + ) + + +def _unit(row: dict[str, Any]) -> Money3: + return Money3( + material=_money(row.get("contract_unit_material_krw")), + labor=_money(row.get("contract_unit_labor_krw")), + expense=_money(row.get("contract_unit_expense_krw")), + ) + + +def cost_items(cost_data: Any, cost_result: Any) -> list[tuple[str, str, Decimal]]: + """계약 원가계산서의 제잡비 줄 (키, 이름, 도급액) — 분리발주 폐기물은 도급 밖이라 뺌.""" + items = [] + for key, name in _ITEM_ORDER: + if not cost_result.has(key): + continue + if key == "waste_disposal" and cost_data.waste_separate_order: + continue + items.append((key, name, cost_result.amount(key))) + return items + + +def contract_ratios( + items: list[tuple[str, str, Decimal]], + contract_direct: Decimal, + overrides: dict[str, dict[str, str]], +) -> dict[str, tuple[Decimal, Decimal]]: + """줄 → (적용 비율, 역산 비율) — 기본은 도급액 ÷ 계약 직접공사비, 직접입력(사유 있음)이 이김.""" + ratios = {} + for key, _, amount in items: + derived = amount / contract_direct if contract_direct else _ZERO + override = overrides.get(key) + ratios[key] = ( + Decimal(override["rate_pct"]) / _HUNDRED if override else derived, + derived, + ) + return ratios + + +def _round_money( + rows: list[dict[str, Any]], + entry: dict[str, Any], + ratios: dict[str, tuple[Decimal, Decimal]], +) -> dict[str, Any]: + """한 회차의 금회 — 줄 금액 · 직접공사비 · 제잡비 줄 · 공급가액 · 부가세 · 절사 몫.""" + lines: dict[str, Money3] = {} + direct = Money3() + for row in rows: + qty = entry["quantities"].get(str(row.get("item_no"))) + if qty is None or not _leaf(row): + continue + lines[str(row["item_no"])] = bill_line(_unit(row), Decimal(qty)) + direct += lines[str(row["item_no"])] + raw = {key: _floor(direct.total * ratio) for key, (ratio, _) in ratios.items()} + items = dict(raw) + notes: list[str] = [] + profit_manual = entry.get("profit_manual_krw") or "" + if profit_manual and not entry.get("total_cut_krw") and "profit" in items: + items["profit"] = Decimal(profit_manual) + notes.append(f"이윤금액 직접입력 {Decimal(profit_manual):,.0f}원") + + def vat_of(supply: Decimal) -> Decimal: + if entry["vat_mode"] == "manual": + return Decimal(entry["vat_manual_krw"] or 0) + base = vat_base(entry["vat_mode"], supply, direct.material, direct.expense, _ZERO) + return _floor(base * _VAT_PERCENT / _HUNDRED) + + def take_from_profit(amount: Decimal, label: str) -> bool: + if "profit" not in items: + notes.append(f"{label} {amount:,.0f}원 — 이윤 줄이 없어 못 뺌") + return False + items["profit"] -= amount + return True + + supply = direct.total + sum(items.values(), _ZERO) + if entry.get("supply_cut_krw"): + gap = cut_gap(supply, int(entry["supply_cut_krw"])) + if gap and take_from_profit(gap, "공급가액 절사"): + supply -= gap + notes.append( + f"공급가액 {int(entry['supply_cut_krw']):,}원 미만 절사 — 이윤에서 {gap:,.0f}원" + ) + vat = vat_of(supply) + if entry.get("total_cut_krw"): + # 설계 원가계산서와 같은 식 — 첫 차례는 공급가액 비례 부가세면 ÷1.1, 그 뒤는 잔차 그대로. + unit, taken = int(entry["total_cut_krw"]), _ZERO + for _ in range(_CUT_MAX_PASSES): + gap = cut_gap(supply + vat, unit) + if gap == 0: + break + step = profit_cut(gap, "grand_total", entry["vat_mode"]) if taken == 0 else gap + if not take_from_profit(step, "총공사비 절사"): + break + taken += step + supply -= step + vat = vat_of(supply) + if taken: + notes.append(f"총공사비 {unit:,}원 미만 절사 — 이윤에서 {taken:,.0f}원") + return { + "lines": lines, + "direct": direct, + "raw_items": raw, + "items": items, + "supply": supply, + "vat": vat, + "notes": notes, + } + + +def progress_sheet( + contract_rows: list[dict[str, Any]], + cost_data: Any, + cost_result: Any, + settings: dict[str, Any], + round_no: int | None = None, +) -> dict[str, Any]: + """기성내역 · 기성 제잡비 계산서 한 장 — `round_no`(1부터) 회차를 금회로. 없으면 마지막 회차.""" + rounds = settings.get("rounds") or [] + current = len(rounds) if round_no is None else max(0, min(round_no, len(rounds))) + contract_direct = sum( + (_money(r["contract_amount_krw"]) for r in contract_rows if _leaf(r)), _ZERO + ) + items = cost_items(cost_data, cost_result) + overrides = settings.get("rate_overrides") or {} + ratios = contract_ratios(items, contract_direct, overrides) + per_round = [_round_money(contract_rows, entry, ratios) for entry in rounds] + empty = { + "lines": {}, + "direct": Money3(), + "raw_items": {}, + "items": {}, + "supply": _ZERO, + "vat": _ZERO, + "notes": [], + } + before = per_round[: max(current - 1, 0)] + now = per_round[current - 1] if current else empty + + def four(pick) -> dict[str, Decimal]: + """전회(앞 회차 금회의 합) · 금회 · 누계(전회 + 금회).""" + previous = sum((pick(r) for r in before), _ZERO) + return {"previous": previous, "current": pick(now), "cumulative": previous + pick(now)} + + rows: list[dict[str, Any]] = [] + for source in contract_rows: + row = dict(source) + rows.append(row) + if not _leaf(row): + continue + item_no = str(row.get("item_no")) + qty_col = { + "previous": sum( + (Decimal(rounds[i]["quantities"].get(item_no, "0")) for i in range(len(before))), + _ZERO, + ), + "current": Decimal(rounds[current - 1]["quantities"].get(item_no, "0")) + if current + else _ZERO, + } + qty_col["cumulative"] = qty_col["previous"] + qty_col["current"] + money = { + "previous": sum((r["lines"].get(item_no, Money3()) for r in before), Money3()), + "current": now["lines"].get(item_no, Money3()), + } + money["cumulative"] = money["previous"] + money["current"] + contract_qty = _money(row.get("quantity")) + for col in _COLUMNS: + row.update( + { + f"progress_{col}_quantity": str(qty_col[col]), + f"progress_{col}_material_krw": str(money[col].material), + f"progress_{col}_labor_krw": str(money[col].labor), + f"progress_{col}_expense_krw": str(money[col].expense), + f"progress_{col}_amount_krw": str(money[col].total), + } + ) + row["progress_pct"] = _pct(qty_col["cumulative"], contract_qty) + row["progress_remaining_quantity"] = str(contract_qty - qty_col["cumulative"]) + row["progress_note"] = ( + "누계 기성수량이 계약 수량을 넘음" if qty_col["cumulative"] > contract_qty else "" + ) + # 사정 — 뜻 미확인(27번 §3.2 「기성내역서(사정)」 열 이름뿐)이라 칸만 받고 계산에 안 씀. + row["progress_assessed_krw"] = ( + rounds[current - 1]["assessed"].get(item_no, "") if current else "" + ) + for col in _COLUMNS: + _group_sums(rows, f"progress_{col}") + + contract_items_total = sum((amount for _, _, amount in items), _ZERO) + item_rows = [] + for key, name, amount in items: + values = four(lambda r, k=key: r["items"].get(k, _ZERO)) + ratio, derived = ratios[key] + item_rows.append( + { + "key": key, + "name": name, + "contract_krw": str(amount), + "contract_ratio_pct": str((ratio * _HUNDRED).quantize(Decimal("0.001"))), + "default_ratio_pct": str((derived * _HUNDRED).quantize(Decimal("0.001"))), + "override": overrides.get(key), + **{f"{col}_krw": str(values[col]) for col in _COLUMNS}, + **{f"{col}_pct": _pct(values[col], amount) for col in _COLUMNS}, + } + ) + contract_supply = contract_direct + contract_items_total + summary = [] + for key, name, contract_amount, pick in ( + ("direct", "직접공사비", contract_direct, lambda r: r["direct"].total), + ("items", "제잡비 계", contract_items_total, lambda r: sum(r["items"].values(), _ZERO)), + ("supply", "공급가액", contract_supply, lambda r: r["supply"]), + ("vat", "부가가치세", cost_result.amount("vat"), lambda r: r["vat"]), + ( + "total", + "기성금액", + contract_supply + cost_result.amount("vat"), + lambda r: r["supply"] + r["vat"], + ), + ): + values = four(pick) + summary.append( + { + "key": key, + "name": name, + "contract_krw": str(contract_amount), + **{f"{col}_krw": str(values[col]) for col in _COLUMNS}, + **{f"{col}_pct": _pct(values[col], contract_amount) for col in _COLUMNS}, + } + ) + # 단일율 곱과의 차이 — 줄마다 원 미만 절사해 쌓아서 벌어짐(절사 보정 전 값끼리 댐). + single = sum((ratio for ratio, _ in ratios.values()), _ZERO) + raw_sum = four(lambda r: sum(r["raw_items"].values(), _ZERO)) + direct_sum = four(lambda r: r["direct"].total) + gaps = { + col: _floor(direct_sum[col] * single) - raw_sum[col] for col in ("current", "cumulative") + } + notes = list(now["notes"]) + if any(gaps.values()): + notes.append( + f"줄별 절사 누적으로 단일율 곱과 금회 {gaps['current']:,.0f}원 · " + f"누계 {gaps['cumulative']:,.0f}원 차이" + ) + if cost_data.indirect_material_krw: + notes.append( + f"계약 간접재료비 {cost_data.indirect_material_krw:,.0f}원은 기성 제잡비 줄 밖" + "(예정가격작성기준 제17조 재료비 안 · 제39조② 간접공사비 목록 밖)" + ) + unknown = sorted(set(overrides) - set(ratios)) + if unknown: + notes.append(f"계약잡비율 직접입력이 원가계산서에 없는 줄을 가리킴 — {', '.join(unknown)}") + return { + "rows": rows, + "items": item_rows, + "summary": summary, + "single_rate_gap_krw": {col: str(gap) for col, gap in gaps.items()}, + "contract_overhead_ratio_pct": str((single * _HUNDRED).quantize(Decimal("0.001"))), + "round": current, + "round_count": len(rounds), + "vat": rounds[current - 1] if current else None, + "notes": notes, + } + + +def completion_sheet(progress: dict[str, Any]) -> dict[str, Any]: + """준공 — 「계약금액 | 준공금액」 두 열(27번 §5 · 35번 §3 `준공조서(을/병)`). + + ⚠ 준공 별도 계산 규칙은 **미확인** — 지어내지 않고 기성 **마지막 회차 누계**를 옮기기만 함. + 기성 한 장(`progress_sheet`, 마지막 회차)을 읽기만 하고 고치지 않음. 회차가 없으면 옮길 + 누계가 없어 준공금액을 비워 둠(0 원으로 안 채움). + """ + has_rounds = bool(progress.get("round")) + + def moved(value: Any) -> str | None: + return value if has_rounds and value is not None else None + + rows = [ + { + **{key: row.get(key) for key in ("item_no", "name", "spec", "unit", "is_group")}, + "contract_amount_krw": row.get("contract_amount_krw"), + "completion_amount_krw": moved(row.get("progress_cumulative_amount_krw")), + } + for row in progress["rows"] + if row.get("in_bill", True) + ] + lines = [ + { + "key": line["key"], + "name": line["name"], + "total": total, + "contract_krw": line["contract_krw"], + "completion_krw": moved(line.get("cumulative_krw")), + "completion_pct": line.get("cumulative_pct") if has_rounds else None, + } + for lines_of, total in ((progress["items"], False), (progress["summary"], True)) + for line in lines_of + ] + return { + "rows": rows, + "lines": lines, + "from_round": progress.get("round") or 0, + "notes": [] if has_rounds else ["기성 회차 없음 — 옮길 누계가 없어 준공금액 비움"], + } diff --git a/B09_Estimation/B09_Estimation_QuantityDigits.py b/B09_Estimation/B09_Estimation_QuantityDigits.py index edd3363c..f5c4d3e2 100644 --- a/B09_Estimation/B09_Estimation_QuantityDigits.py +++ b/B09_Estimation/B09_Estimation_QuantityDigits.py @@ -16,9 +16,10 @@ **항목에서 제시하는 소숫자리를 우선**」). 그래서 `override` 를 받는다. 다만 지금 공종 마스터에 「이 항목의 소수자리」를 담은 칸이 **아직 없다** — 그 칸이 생기면 여기로 흘리면 된다. -⚠ **모르는 종목은 손대지 않는다.** 규칙을 넓게 잡아 엉뚱한 줄까지 자르는 사고를 오늘만 -여러 번 겪었다. 표에 없으면 `None` 을 돌려주고 **화면이 종전대로** 찍게 둔다 — -「모른다」가 보이는 편이 조용히 틀리는 것보다 낫다. +⚠ **표에 없는 종목은 우리 기본 소수 2자리**(2026-09-14 브레인 판정 — 종전 「손대지 않음」 폐기). +수량을 이 자리로 **먼저 확정한 뒤** 금액을 셈 — [주]① 이 시킨 차례이고, 명세의 「Q 를 소수 2자리로 +먼저 확정한 뒤 나눔」과 같은 원칙(내역 줄 `BillOfQuantities_Rows.settle_quantity`). +못 맞춘 이름은 종전대로 로그로 드러냄. """ from __future__ import annotations @@ -50,6 +51,9 @@ _DIGITS: tuple[tuple[tuple[str, ...], tuple[str, ...], int], ...] = ( (("철강재", "강재"), ("kg", "㎏"), 3), (("용접봉",), ("kg", "㎏"), 1), (("철근",), ("kg", "㎏"), 0), + # 같은 줄의 단위 환산 — 원문 「철근 kg 정수」 = ton 소수 3자리. STmate 실무 원본 영월·울진 + # 설계내역서 철근·철근운반 Ton 0.264·0.008 이 그 자리(2026-09-14 골든셋 대조 · 원문 값 아님). + (("철근",), ("ton", "톤"), 3), (("볼트", "너트", "꺽쇠"), ("개",), 0), (("철선", "철사"), ("kg", "㎏"), 2), (("못",), ("kg", "㎏"), 2), @@ -68,6 +72,8 @@ _DIGITS: tuple[tuple[tuple[str, ...], tuple[str, ...], int], ...] = ( #: ⚠ **면적을 1자리로 내리지 않는다** — 「토적(단면적)」은 횡단면적을 말하는 것이라 #: 사면적·거푸집 면적까지 1자리로 자르면 틀린다. 확실한 ㎥ 만 잡는다. _UNIT_ONLY = {"㎥": 2, "m3": 2} +#: 표에도 단위에도 안 걸리는 줄의 자리 — 우리 기본(브레인 판정 2026-09-14). +DEFAULT_DIGITS = 2 logger = logging.getLogger(__name__) #: 못 맞춘 줄을 이미 알린 (이름, 단위) — 내역을 그릴 때마다 같은 로그가 쌓이지 않게. @@ -78,15 +84,16 @@ def _tight(text: str) -> str: return "".join(str(text or "").split()) -def digits_for(name: str, unit: str, spec: str = "") -> int | None: - """그 줄의 수량 소수자리. **표에 없으면 `None`** — 지어내지 않는다. +def digits_for(name: str, unit: str, spec: str = "") -> int: + """그 줄의 수량 소수자리. 표에 없으면 단위(㎥ 2) → 그것도 없으면 우리 기본 2. ⚠ 이름으로 종목을 찾는다 — 품셈 1-2-2 표 자체가 종목 이름 표라 코드로 옮길 대응표가 없음 (금액이 아닌 표시 자리). **이름을 못 맞춘 줄은 로그**로 남긴다 — 마스터 갈래 키(1장)가 서면 함께 정리함(2026-09-13 브레인 판정: 이름은 그대로 두고 못 맞춘 것만 드러냄). """ haystack = _tight(name) + _tight(spec) - unit_tight = _tight(unit) + # 단위 대소문자는 뜻이 같음(실무 원본 「M3」·「TON」·「Ton」) — 표기만 흡수. + unit_tight = _tight(unit).lower() for words, units, digits in _DIGITS: if unit_tight not in units: continue @@ -100,9 +107,11 @@ def digits_for(name: str, unit: str, spec: str = "") -> int | None: "B09 수량 자리 — 종목 이름을 못 맞춤: 「%s」 %s → %s", name, unit, - "단위만으로 %s자리" % fallback if fallback is not None else "표시 자리 없음", + "단위만으로 %s자리" % fallback + if fallback is not None + else "기본 %s자리" % DEFAULT_DIGITS, ) - return _UNIT_ONLY.get(unit_tight) + return _UNIT_ONLY.get(unit_tight, DEFAULT_DIGITS) def round_quantity( @@ -114,11 +123,9 @@ def round_quantity( ) -> tuple[Decimal, int | None]: """수량을 그 종목의 자리로 **반올림**한다. - 돌려주는 것 — (자른 값, 쓴 자리). 자리를 못 찾으면 **값을 안 건드리고** `(값, None)`. - `override` 는 품셈 **항목이 따로 제시한 자리**([주]②) — 표보다 우선한다. + 돌려주는 것 — (자른 값, 쓴 자리). + `override` 는 품셈 **항목이 따로 제시한 자리**([주]②) — 표보다 우선한다(마스터 칸 대기). """ digits = override if override is not None else digits_for(name, unit, spec) - if digits is None: - return value, None quantum = Decimal(1).scaleb(-digits) return value.quantize(quantum, rounding=ROUND_HALF_UP), digits diff --git a/B09_Estimation/B09_Estimation_RateOverride.py b/B09_Estimation/B09_Estimation_RateOverride.py new file mode 100644 index 00000000..90fdc4c7 --- /dev/null +++ b/B09_Estimation/B09_Estimation_RateOverride.py @@ -0,0 +1,122 @@ +"""프로젝트 요율 덮어쓰기 — 발주처별 별도요율 (PLAN 6장 새 절 · 2026-09-14 브레인 판정). + +⚠ **프로젝트 단위로만** — 마스터 요율 파일(`rates_<연도>.json`)은 안 고친다. 고치면 남의 + 프로젝트가 흔들린다. 프로젝트 설정 `estimation.rate_overrides` 에 칸마다 한 줄씩 두고, + 계산할 때 **복사본에** 얹는다. +⚠ **사유 없는 덮어쓰기는 안 받음** — 「이 값이 왜 다르지」가 나중에 풀려야 한다. + +한 줄 모양 + {"variable": "rate_overhead", "table": "civil_landscape_industrial", + "match": {"estimated_price_bracket": "lt_5_billion"}, + "rate_percent": "7.5", "reason": "발주처 지침 …", "entered_at": "2026-09-14T…"} + `table` 이 없으면 변수 자체의 `rate_percent`(산재·건강 따위 한 값짜리). +""" + +from __future__ import annotations + +import copy +from dataclasses import replace +from decimal import Decimal, InvalidOperation +from typing import Any + +from B09_Estimation.B09_Estimation_Rates import RateDataset, RateLookupError + +#: 줄을 가르는 칸 — 이 칸들로 같은 표 안의 줄 하나를 집는다(요율·기초액·식은 가르는 칸 아님). +IDENTITY_FIELDS = ( + "estimated_price_bracket", + "direct_cost_bracket", + "duration_bracket", + "target_amount_bracket", + "estimated_amount_bracket", + "work_type", + "grade", + "year", +) + + +def address_of(variable: str, table: str | None, row: dict[str, Any] | None) -> dict[str, Any]: + """요율 칸 하나의 주소 — 화면이 들고 다니다 저장 때 그대로 돌려줌.""" + match = {key: row[key] for key in IDENTITY_FIELDS if row and key in row} + return {"variable": variable, "table": table, "match": match} + + +def _target(variables: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """덮어쓸 dict(줄 또는 변수) — 못 찾거나 둘 이상이면 멈춤(조용히 딴 칸을 고치지 않음).""" + variable = variables.get(str(override.get("variable") or "")) + if not isinstance(variable, dict): + raise RateLookupError(f"요율 덮어쓰기: 없는 항목 — {override.get('variable')}") + table = override.get("table") + if not table: + if "rate_percent" not in variable: + raise RateLookupError(f"요율 덮어쓰기: 한 값짜리가 아님 — {override.get('variable')}") + return variable + rows = variable.get(str(table)) + if not isinstance(rows, list): + raise RateLookupError(f"요율 덮어쓰기: 없는 표 — {override.get('variable')}.{table}") + match = override.get("match") or {} + hits = [ + row + for row in rows + if isinstance(row, dict) + and "rate_percent" in row + and all(str(row.get(key)) == str(value) for key, value in match.items()) + ] + if len(hits) != 1: + raise RateLookupError( + f"요율 덮어쓰기: 줄을 하나로 못 집음({len(hits)}건) —" + f" {override.get('variable')}.{table} {match}" + ) + return hits[0] + + +def apply_overrides(dataset: RateDataset, overrides: Any) -> RateDataset: + """복사본에 덮어쓴 요율판. 덮어쓰기가 없으면 원본 그대로(캐시 공유).""" + if not overrides: + return dataset + variables = copy.deepcopy(dataset.variables) + for override in overrides: + _target(variables, override)["rate_percent"] = float(Decimal(str(override["rate_percent"]))) + return replace(dataset, variables=variables) + + +def clean_overrides(raw: Any, dataset: RateDataset) -> tuple[list[dict[str, Any]], list[str]]: + """저장할 덮어쓰기와 거른 까닭. 사유가 비거나·주소가 틀리거나·음수면 버림.""" + kept: list[dict[str, Any]] = [] + errors: list[str] = [] + seen: set[str] = set() + for item in raw if isinstance(raw, list) else []: + if not isinstance(item, dict): + continue + label = f"{item.get('variable')}.{item.get('table') or ''} {item.get('match') or {}}" + reason = str(item.get("reason") or "").strip() + if not reason: + errors.append(f"사유가 비어 있음 — {label}") + continue + try: + value = Decimal(str(item.get("rate_percent"))) + except (InvalidOperation, ValueError): + errors.append(f"요율이 수가 아님 — {label}") + continue + if value < 0: + errors.append(f"음수 요율 — {label}") + continue + entry = { + "variable": str(item.get("variable") or ""), + "table": item.get("table") or None, + "match": dict(item.get("match") or {}), + "rate_percent": str(value), + "reason": reason, + "entered_at": str(item.get("entered_at") or ""), + } + try: + _target(copy.deepcopy(dataset.variables), entry) + except RateLookupError as error: + errors.append(str(error)) + continue + key = f"{entry['variable']}|{entry['table']}|{sorted(entry['match'].items())}" + if key in seen: + errors.append(f"같은 칸을 두 번 고침 — {label}") + continue + seen.add(key) + kept.append(entry) + return kept, errors diff --git a/B09_Estimation/B09_Estimation_RateTable.py b/B09_Estimation/B09_Estimation_RateTable.py new file mode 100644 index 00000000..1923cdd4 --- /dev/null +++ b/B09_Estimation/B09_Estimation_RateTable.py @@ -0,0 +1,291 @@ +"""B09 제비율 요율표 화면 자료 — STmate 「원가계산기준」(wM_KAN_RATE) 첫 탭 차례 (PLAN 12장). + +⚠ 묶음 제목·산식 표기는 DFM `TWM_KAN_RATE` 첫 탭(☞ 공사원가계산 제잡비율) 차례, + 값·구간은 우리 데이터. +⚠ 요율 칸마다 **주소**(`RateOverride.address_of`)와 **기본값(마스터)·지금 값(프로젝트)** 을 + 함께 실음 — 화면이 「기본값 ↔ 고친 값」을 갈라 보이고 [수 정]에서 그 칸만 고쳐 사유와 함께 돌려줌. +""" + +from __future__ import annotations + +from decimal import Decimal +from typing import Any + +from B09_Estimation.B09_Estimation_CostSheet import SAFETY_LABELS, WORK_TYPE_LABELS +from B09_Estimation.B09_Estimation_RateOverride import address_of +from B09_Estimation.B09_Estimation_Rates import RateDataset, _bracket_bounds, _duration_bounds + +_EOK = Decimal(100_000_000) + + +def _won(amount: Decimal) -> str: + """원 → 「50억」·「5천만」 표기.""" + if amount >= _EOK: + return f"{amount / _EOK:g}억" + return f"{amount / 10_000_000:g}천만" + + +def amount_label(label: str) -> str: + """구간 키 → 사람 표기. 모르는 키는 그대로(지어내지 않음).""" + bounds = _bracket_bounds(label) + if bounds is None: + return label + low, high = bounds + if low == 0: + return f"{_won(high)} 미만" + if high == Decimal("Infinity"): + return f"{_won(low)} 이상" + (" " + label.split("_", 3)[-1] if label.count("_") > 2 else "") + return f"{_won(low)}~{_won(high)} 미만" + + +def duration_label(label: str) -> str: + bounds = _duration_bounds(label) + if bounds is None: + return label + low, high = bounds + if low == 0: + return f"{high}일 이하" + if high >= 10**9: + return f"{low}일 이상" + return f"{low}~{high}일" + + +def _type(value: str, safety: bool = False) -> str: + return {**WORK_TYPE_LABELS, **(SAFETY_LABELS if safety else {})}.get(value, value) + + +class _Cells: + """요율 칸 만들기 — 지금 판(덮어쓴 복사본)과 마스터 판을 같은 주소로 맞대 봄.""" + + def __init__(self, current: RateDataset, master: RateDataset) -> None: + self.current, self.master = current.variables, master.variables + + def cell(self, variable: str, table: str | None, row: dict[str, Any] | None) -> dict[str, Any]: + address = address_of(variable, table, row) + value = self._rate(self.current, address) + default = self._rate(self.master, address) + return { + "value": value, + "default": default, + "overridden": value != default, + "address": address, + } + + @staticmethod + def _rate(variables: dict[str, Any], address: dict[str, Any]) -> Any: + variable = variables[address["variable"]] + if not address["table"]: + return variable.get("rate_percent") + for row in variable[address["table"]]: + if all(str(row.get(k)) == str(v) for k, v in address["match"].items()): + return row.get("rate_percent") + return None + + +def _pivot(rows: list[dict[str, Any]], row_keys: tuple[str, ...], col_key: str): + """세로 줄을 (구간…) × 공종 표로 편다 — DFM 표 모양(행 구간 · 열 공종).""" + columns = list(dict.fromkeys(str(row[col_key]) for row in rows)) + grid: dict[tuple[str, ...], dict[str, Any]] = {} + for row in rows: + grid.setdefault(tuple(str(row[k]) for k in row_keys), {})[str(row[col_key])] = row + return columns, grid + + +def rate_sections(dataset: RateDataset, master: RateDataset | None = None) -> list[dict[str, Any]]: + """묶음 목록 `{title, formula, columns, rows}` — DFM 첫 탭 위→아래 차례. + + `rows` 의 요율 칸은 `{value, default, overridden, address}` · 이름 칸은 글자. + """ + v = dataset.variables + c = _Cells(dataset, master or dataset) + sections: list[dict[str, Any]] = [] + + profit = {row["estimated_price_bracket"]: row for row in v["rate_profit"]["brackets"]} + sections.append( + { + "title": "일반관리비 · 이윤", + "formula": "일반관리비 (재+노+경) × 율 · 이윤 (노+경+일) × 율", + "columns": ["공사규모(추정가격)", "일반관리비(%)", "이윤(%)"], + "rows": [ + [ + amount_label(row["estimated_price_bracket"]), + c.cell("rate_overhead", "civil_landscape_industrial", row), + c.cell("rate_profit", "brackets", profit[row["estimated_price_bracket"]]) + if row["estimated_price_bracket"] in profit + else "", + ] + for row in v["rate_overhead"]["civil_landscape_industrial"] + ], + } + ) + for variable, title, formula in ( + ("rate_indirect_labor", "간접노무비", "(직접노무비) × 율(%)"), + ("rate_other_expense", "기타경비", "(재료비+노무비) × 율(%)"), + ): + columns, grid = _pivot( + v[variable]["brackets"], ("direct_cost_bracket", "duration_bracket"), "work_type" + ) + sections.append( + { + "title": title, + "formula": formula, + "columns": ["공사규모(직접공사비)", "공사기간", *(_type(col) for col in columns)], + "rows": [ + [ + amount_label(size), + duration_label(days), + *( + c.cell(variable, "brackets", cells[col]) if col in cells else "" + for col in columns + ), + ] + for (size, days), cells in grid.items() + ], + } + ) + sections.append( + { + "title": "고용보험료", + "formula": "(노무비) × 율", + "columns": ["등급", "추정금액", "요율(%)"], + "rows": [ + [ + str(row["grade"]), + amount_label(row["estimated_amount_bracket"]), + c.cell("rate_goyong", "brackets", row), + ] + for row in v["rate_goyong"]["brackets"] + ], + } + ) + columns, grid = _pivot( + v["rate_safety_pct"]["brackets"], ("target_amount_bracket",), "work_type" + ) + sections.append( + { + "title": "산업안전보건관리비", + "formula": "(재료비+직접노무비+관급자재) × 율 + 기초액", + "columns": [ + "대상액", + *(f"{_type(col, safety=True)}(%)" for col in columns), + "기초액(원)", + ], + "rows": [ + [ + amount_label(size), + *( + c.cell("rate_safety_pct", "brackets", cells[col]) if col in cells else "" + for col in columns + ), + str( + next( + ( + cells[col].get("base_amount_krw") + for col in columns + if cells.get(col, {}).get("base_amount_krw") + ), + "", + ) + ), + ] + for (size,), cells in grid.items() + ], + } + ) + sections.append( + { + "title": "환 경 보 전 비", + "formula": "(재료비+직접노무비+산출경비) × 율", + "columns": ["구 분", "요율(%)"], + "rows": [ + [_type(row["work_type"]), c.cell("rate_environment", "all_work_types", row)] + for row in v["rate_environment"]["all_work_types"] + ], + } + ) + sections.append( + { + "title": "공사이행보증수수료", + "formula": "[추가금액 + (직접공사비-기준금액) × 율] × 공기(년)", + "columns": ["공사규모(직접공사비)", "수수료 산식"], + "rows": [ + [ + amount_label(row["direct_cost_bracket"]), + str(row.get("formula", "")) + .replace("direct_cost", "직공비") + .replace("duration_years", "공기(년)"), + ] + for row in v["rate_performance_guarantee_fee"]["brackets"] + ], + } + ) + sections.append( + { + "title": "건설하도급대금지급보증서발급수수료", + "formula": "(직접공사비) × 율", + "columns": ["공사규모", "요율(%)"], + "rows": [ + [ + amount_label(row["estimated_price_bracket"]), + c.cell("rate_subcontract_payment_guarantee", "brackets", row), + ] + for row in v["rate_subcontract_payment_guarantee"]["brackets"] + ], + } + ) + equipment = v["rate_equipment_payment_guarantee"] + sections.append( + { + "title": "건설기계대여대금 지급보증서 발급금액", + "formula": "(직접공사비) × 율", + "columns": ["구 분", "요율(%)"], + "rows": [ + *( + [ + _type(row["work_type"]), + c.cell("rate_equipment_payment_guarantee", "general_construction", row), + ] + for row in equipment["general_construction"] + ), + *( + [ + "전문건설 — " + row["work_type"], + c.cell("rate_equipment_payment_guarantee", "specialty_construction", row), + ] + for row in equipment["specialty_construction"] + ), + ], + } + ) + year = int(str(dataset.effective_date)[:4] or 0) + pension_row = next((r for r in v["rate_pension"]["annual_rates"] if r["year"] == year), None) + sections.append( + { + "title": "퇴직부금비 · 법 정 부 담 금 · 보험료", + "formula": "", + "columns": ["항목", "산식", "요율(%)"], + "rows": [ + [ + "퇴직공제부금비", + "(직접노무비) × 율", + c.cell("rate_retirement_mutual_aid", None, None), + ], + ["석면분담금", "(노무비) × 율", c.cell("rate_asbestos_contribution", None, None)], + [ + "임금채권부담금", + "(노무비) × 율", + c.cell("rate_wage_claim_contribution", None, None), + ], + ["산재보험료", "(노무비) × 율", c.cell("rate_sanjae", None, None)], + ["건강보험료", "(직접노무비) × 율", c.cell("rate_health", None, None)], + ["장기요양보험료", "(건강보험료) × 율", c.cell("rate_care", None, None)], + [ + "연금보험료", + "(직접노무비) × 율", + c.cell("rate_pension", "annual_rates", pension_row) if pension_row else "", + ], + ["부가가치세", "(공급가액) × 율", c.cell("rate_vat", None, None)], + ], + } + ) + return sections diff --git a/B09_Estimation/B09_Estimation_ResourceAxis.py b/B09_Estimation/B09_Estimation_ResourceAxis.py index b964d3d1..761ee7f0 100644 --- a/B09_Estimation/B09_Estimation_ResourceAxis.py +++ b/B09_Estimation/B09_Estimation_ResourceAxis.py @@ -440,6 +440,11 @@ def match_table( if match_unit_basis_table(node, table, catalog, result): return + # 사람이 모양을 읽어 둔 표(9-19-1 절토면·성토면 · 2026-09-14 판정) — 적어 둔 표만. + from B09_Estimation.B09_Estimation_ResourceAxis_JudgedTable import match_judged_table + if match_judged_table(node, table, catalog, result, basis_quantity, unit): + return + # ⚠ **자원이 열 머리에 오는 표가 따로 있다** (2026-09-08 발견, 39 표). # 「구 분 | 콘크리트공(인) | 보통인부(인)」처럼 **열이 자원**이고 행은 규격 갈래 # (무근·철근·소형구조물)다. 행을 자원으로 읽는 길로 보내면 통째로 못 맞춘다 — @@ -574,8 +579,10 @@ def match_table( # 정상 자원이 조용히 사라진다(2026-09-07 실측 — 부분일치 필터가 매칭 14건을 # 지우고 있었음). 카탈로그에 있는 이름은 **정의상 자원**이다. # 범위 별칭 — 그 공종 범위 안에서만 카탈로그 쪽 이름으로 바꾼다(「화약공」 → 화약취급공). - name_cell = apply_scoped_alias(catalog, name_cell, node["work_item_code"]) - entry = _resolve_cell(catalog, name_cell, [name_cell, *value_cells]) + # 별칭이 코드를 적었으면 그 항목으로 곧장(규격이 조인 키인 기계가 다시 흐려지지 않게). + entry = scoped_alias_entry(catalog, name_cell, node["work_item_code"], value_cells) + name_cell = apply_scoped_alias(catalog, name_cell, node["work_item_code"], value_cells) + entry = entry or _resolve_cell(catalog, name_cell, [name_cell, *value_cells]) if entry is None: if is_non_resource_label(name_cell): continue # 머리글·소계 — 못 맞춘 목록에도 안 올린다 @@ -699,6 +706,7 @@ from B09_Estimation.B09_Estimation_ResourceAxis_Sources import ( # noqa: E402 from B09_Estimation.B09_Estimation_ResourceAxis_Join import ( # noqa: E402 apply_scoped_alias, pick_by_spec, + scoped_alias_entry, resolve_family, text_spec_candidates, unmatched_reason, diff --git a/B09_Estimation/B09_Estimation_ResourceAxis_ChooseOne.py b/B09_Estimation/B09_Estimation_ResourceAxis_ChooseOne.py index bcdb40ee..709d5e43 100644 --- a/B09_Estimation/B09_Estimation_ResourceAxis_ChooseOne.py +++ b/B09_Estimation/B09_Estimation_ResourceAxis_ChooseOne.py @@ -191,6 +191,10 @@ def match_choose_one_machine_table( if made == 0: return False + if per != 1: + # 밑수를 사용자가 정해 빌려 온 표(확정 5차 6번 제근) — 안내 글은 「못 붙은 줄」이 아니라 + # 내역·본표 비고(`KnownGaps.known_gap_note` ← `borrowed_unit_note`)로 뜸(2026-09-14). + return True result.unmatched.append( UnmatchedRow( work_item_code=work_item_code, @@ -269,34 +273,75 @@ def match_spec_column_table( if len(specs) < 2 or not all(_SPEC_HEAD.match(spec) for spec in specs): return False + from B09_Estimation.B09_Estimation_ResourceAxis_Join import resolve_family, unmatched_reason + unit = table.get("basis_unit") or "" work_item_code = str(node.get("work_item_code", "")) table_id = str(table.get("pum_table_id", "")) form = str(table.get("pum_form", "")) made = 0 + previous_note = "" + + def unmatched(cell: str, reason: str) -> None: + result.unmatched.append( + UnmatchedRow( + work_item_code=work_item_code, pum_table_id=table_id, cell=cell, reason=reason + ) + ) + for index, row in enumerate(rows[1:], start=1): cells = [_clean(cell) for cell in row] - if not cells: + if not cells or not cells[0]: continue - name, spec_text = split_name_and_spec(cells[0]) - entry = catalog.resolve(name, spec_text) or catalog.resolve(name, "") - if entry is None: - result.unmatched.append( - UnmatchedRow( - work_item_code=work_item_code, - pum_table_id=table_id, - cell=cells[0], - reason="카탈로그에 없는 이름 (규격이 열로 선 표) — 0 으로 때우지 않습니다.", - ) + # 줄 모양 — | 구분 | 규격 | 단위 | 관경별 값 … | 비고 | (2026-09-14 · 원문 12-11 표 머리) + values = [ + value for value in (_cell_amount(cell) for cell in cells[1:]) if value is not None + ] + note = cells[-1] if len(cells) > 1 and _cell_amount(cells[-1]) is None else "" + note = previous_note if note == "〃" else note + spec_cell = cells[1] if len(cells) > 1 and _cell_amount(cells[1]) is None else "" + if not values and not note and previous_note.replace(" ", "") == "설계수량": + note = previous_note # 원문 병합 칸 — 바로 위 줄 비고를 따름(흄관 거푸집) + previous_note = note + tight_note = note.replace(" ", "") + # ⚠ 원문 비고가 「이 일위대가 밖」인 줄 — 못 찾은 게 아니라 안 넣는 것 · 까닭을 남김. + if tight_note in ("별산", "별도계산"): + unmatched( + cells[0], f"「{note}」 — 원문 비고대로 이 일위대가 밖에서 따로 셈 · 여기 안 넣음" ) continue - values = [_cell_amount(cell) for cell in cells[1:]] - values = [value for value in values if value is not None] - if len(values) < len(specs): - # 값이 빈 규격이 있다 — 「별산」 줄이거나 표가 덜 찼다. 짐작해 채우지 않는다. + if tight_note == "설계수량": + unmatched(cells[0], "「설계수량」 — 원문 비고대로 설계 물량으로 따로 셈 · 여기 안 넣음") continue - for spec_name, amount in zip(specs, values[-len(specs) :]): + if len(values) < len(specs): + why = f"「{note}」 — " if note else "" + unmatched(cells[0], f"{why}관경별 값이 비어 짐작해 채우지 않음") + continue + name, inline_spec = split_name_and_spec(cells[0]) + row_specs = [text for text in (spec_cell, inline_spec) if text] + entries: list[CatalogEntry] = [] + for spec_name in specs: + entry = ( + next( + ( + found + for text in (*row_specs, spec_name) + if (found := catalog.resolve(name, text)) is not None + ), + None, + ) + or catalog.resolve(name, "") + or resolve_family(catalog, cells[0], cells) + ) + if entry is None: + break + entries.append(entry) + if len(entries) < len(specs): + origin = f" · 원문 규격 {spec_cell}" if spec_cell else "" + unmatched(cells[0], f"{unmatched_reason(catalog, name)}{origin} (규격이 열로 선 표)") + continue + for spec_name, amount, entry in zip(specs, values[-len(specs) :], entries): result.rows.append( ResourceRow( work_item_code=work_item_code, diff --git a/B09_Estimation/B09_Estimation_ResourceAxis_Join.py b/B09_Estimation/B09_Estimation_ResourceAxis_Join.py index 778506f4..00d1cc53 100644 --- a/B09_Estimation/B09_Estimation_ResourceAxis_Join.py +++ b/B09_Estimation/B09_Estimation_ResourceAxis_Join.py @@ -107,8 +107,47 @@ def load_scoped_aliases() -> list[dict[str, str]]: return load_aliases("resource") -def apply_scoped_alias(catalog: ResourceCatalog, name_cell: str, work_item_code: str) -> str: +#: 옆 칸 규격 — 「0.6㎥」·「10.3㎥/min」. 값「0.09」·식「(2.45+3.05)/2/10㎡」·단위「hr」 는 아님. +_RE_SIDE_SPEC = re.compile(r"^\d+(?:\.\d+)?(?:㎥|m3|㎥/min|㎥/분|ton|톤|㎾|kW|㎜|mm|㎝|cm|인치)$") + + +def _writes_spec(side_cells: list[str] | tuple[str, ...]) -> bool: + """옆 칸이 규격을 적었나 — 이름이 같아도 별칭이 그 규격을 덮으면 안 됨.""" + return any(_RE_SIDE_SPEC.match(_normalize(cell)) for cell in side_cells) + + +def scoped_alias_entry( + catalog: ResourceCatalog, + name_cell: str, + work_item_code: str, + side_cells: list[str] | tuple[str, ...] = (), +) -> CatalogEntry | None: + """범위 별칭이 가리키는 **항목 그 자체**(코드로 곧장) — 없으면 `None`. + + ⚠ 이름만 돌려주면 규격이 조인 키인 기계(소형브레이커 공압식 넷)는 다시 「규격 미정」 으로 + 흐려짐 — 별칭이 코드를 적었으면 그 코드가 곧 답(2026-09-14 · 9-19-3 소형브레이커). + ⚠ 별칭은 **빈 곳을 채움** — 옆 칸이 규격을 적었으면 안 덮음(같은 9-19-1 성토면 「굴착기 | + 0.6㎥」 가 절토면 [주]① 0.7 로 덮이던 자리 · 2026-09-14 판정 Ⓐ). + """ + if _writes_spec(side_cells): + return None + wanted = _normalize(name_cell) + for row in catalog.scoped_aliases: + if _normalize(row["from"]) != wanted or not in_scope(work_item_code, row["scope"]): + continue + return next((entry for entry in catalog.entries if entry.code == row["to"]), None) + return None + + +def apply_scoped_alias( + catalog: ResourceCatalog, + name_cell: str, + work_item_code: str, + side_cells: list[str] | tuple[str, ...] = (), +) -> str: """범위 안이면 카탈로그 쪽 **이름**으로 바꾼다. 범위 밖이거나 대상 코드가 없으면 원문 그대로.""" + if _writes_spec(side_cells): + return name_cell # 이름만 갈면 옆 칸 규격으로 대상 계열(무한궤도)이 조용히 골라짐 wanted = _normalize(name_cell) for row in catalog.scoped_aliases: if _normalize(row["from"]) != wanted or not in_scope(work_item_code, row["scope"]): diff --git a/B09_Estimation/B09_Estimation_ResourceAxis_JudgedTable.py b/B09_Estimation/B09_Estimation_ResourceAxis_JudgedTable.py new file mode 100644 index 00000000..d15d3f29 --- /dev/null +++ b/B09_Estimation/B09_Estimation_ResourceAxis_JudgedTable.py @@ -0,0 +1,178 @@ +"""B09 원가계산 — **사람이 모양을 읽어 둔 표** (자원 축 보조 · 2026-09-14 ⑤ 브레인 판정 Ⓐ~Ⓔ). + +9-19-1 토사면 고르기(고시 2025-82 원문 L5407~5436)는 한 절에 표가 둘이고 둘 다 일반 길에 안 맞음. + + F0288 1. 절토면 고르기 자원 이름이 **첫 자료 줄**(「보통인부 (인)」 …) · 규격은 [주]① 에만 + · 「·」 = 그 토질엔 그 자원 없음 + F0289 2. 성토면 고르기 병합 첫 칸(시공) 탓에 둘째 줄이 한 칸 앞당겨 옴 + +⚠ 일반 보정으로 넓히지 않음 — 밀린 줄 고쳐 읽기는 목재틀흙막이 503만원 전례(`_Transposed` 머리말). + **적어 둔 표만** 읽고, 칸이 판정과 다르면 한 줄도 안 세우고 막음. +⚠ 규격은 범위 별칭(`data_aliases` scope FP-09-19-01 · [주]①)이 코드로 이음 — 칸이 규격을 적었으면 + 별칭이 안 덮음(`scoped_alias_entry`). 성토면 굴착기 0.6㎥ 는 무한궤도·타이어 둘이라 규격 미정(Ⓑ). +""" + +from __future__ import annotations + +import re +from decimal import Decimal +from typing import Any + +from B09_Estimation.B09_Estimation_ResourceAxis import ( + AxisResult, + ResourceCatalog, + ResourceRow, + UnmatchedRow, + _resolve_cell, + parse_amount, +) +from B09_Estimation.B09_Estimation_ResourceAxis_Join import scoped_alias_entry, unmatched_reason + +#: 표 번호 → (공종 · 모양 · 갈래 앞말 · 판정 근거). 여기 없는 표는 이 길로 안 읽음. +JUDGED_TABLES: dict[str, dict[str, Any]] = { + "F0288": { + "code": "FP-09-19-01", + "shape": "header_row", + "prefix": "절토면", + "why": "원문 L5414 「1. 절토면 고르기 (단위: 10㎡당)」 — 자원 머리가 첫 자료 줄", + }, + "F0289": { + "code": "FP-09-19-01", + "shape": "merged_first", + "prefix": "성토면", + # 원문 L5430 표 — 「인력시공」 칸이 두 줄 병합이라 둘째 줄(모래 또는 사질토)이 앞당겨 옴. + "merged_rows": (1,), + # 기계시공 「굴착기 | 0.6㎥」 — 원문이 형식을 안 적음. 카탈로그 0.6 은 무한궤도·타이어 + # 둘이라 형식마다 갈래 · 고르기는 B08 칸(제안 무한궤도 — 영월 「06M3 B/H」 · 브레인 ②). + "form_split": {"굴착기": ("무한궤도", "타이어")}, + "why": "원문 L5430 「2. 성토면 고르기 (단위: 10㎡당)」 — 시공 칸 병합으로 둘째 줄 앞당김", + }, +} + +#: 「보통인부 (인)」 의 단위 꼬리 — 규격이 아님. +_UNIT_TAIL = re.compile(r"\s*[((](?:인|시간)[))]\s*$") +_ABSENT = frozenset({"·", "ㆍ", "-", "-", "–"}) + + +def _entry(catalog: ResourceCatalog, name_cell: str, code: str, side: tuple[str, ...] = ()): + return scoped_alias_entry(catalog, name_cell, code, side) or _resolve_cell( + catalog, name_cell, [name_cell, *side] + ) + + +def _row( + code: str, table: dict[str, Any], entry, amount: Decimal, unit: str, index: int, variant: str +): + return ResourceRow( + work_item_code=code, + pum_table_id=str(table.get("pum_table_id", "")), + pum_form=str(table.get("pum_form", "")), + resource_kind=entry.kind, + resource_code=entry.code, + resource_name=entry.name, + resource_spec=entry.spec, + amount=amount, + amount_unit=unit, + raw_row_index=index, + variant=variant, + ) + + +def match_judged_table( + node: dict[str, Any], + table: dict[str, Any], + catalog: ResourceCatalog, + result: AxisResult, + basis_quantity: Decimal | None, + unit: str, +) -> bool: + """적어 둔 표를 판정대로 읽음. 그 표가 아니면 `False`.""" + code = str(node.get("work_item_code") or "") + table_id = str(table.get("pum_table_id", "")) + judged = JUDGED_TABLES.get(table_id) + if judged is None or judged["code"] != code: + return False + rows = [[str(c).strip() for c in row] for row in (table.get("raw_row") or [])] + + def block(why: str) -> bool: + result.unmatched.append(UnmatchedRow(code, table_id, judged["prefix"], why)) + result.partial_items[code] = why + return True + + if basis_quantity in (None, 0): + return block("판정 표에 밑수가 없습니다") + if judged["shape"] == "header_row": + staged = _header_row(code, table, judged, rows, catalog, basis_quantity, unit) + else: + staged = _merged_first(code, table, judged, rows, catalog, basis_quantity, unit) + if isinstance(staged, str): + return block(f"{staged} — 판정({judged['why']})과 칸이 달라 안 읽음") + for item in staged: + if isinstance(item, UnmatchedRow): + result.unmatched.append(item) + else: + result.rows.append(item) + return True + + +def _header_row(code, table, judged, rows, catalog, basis, unit) -> list | str: + """첫 줄 = 자원 머리 · 이후 줄 = [토질, 값 …]. 머리 자원을 하나라도 못 풀면 표째 막음.""" + names = [_UNIT_TAIL.sub("", cell) for cell in (rows[0] if rows else []) if cell] + entries = [_entry(catalog, name, code) for name in names] + if len(names) < 2 or not all(entries): + missing = [name for name, entry in zip(names, entries) if entry is None] + return f"자원 머리 {missing or names} 을 못 풂" + staged: list = [] + for index, cells in enumerate(rows[1:], start=1): + values, tail = cells[1 : 1 + len(names)], cells[1 + len(names) :] + readable = all(v in _ABSENT or parse_amount(v) is not None for v in values) + if not cells[0] or len(values) != len(names) or any(tail) or not readable: + return f"{index}째 줄 「{cells[0] if cells else ''}」 값 칸" + variant = f"{judged['prefix']} · {cells[0]}" + for value, entry in zip(values, entries): + if value in _ABSENT: + continue # 그 토질엔 이 자원이 안 듦 + staged.append( + _row(code, table, entry, parse_amount(value) / basis, unit, index, variant) + ) + return staged + + +def _merged_first(code, table, judged, rows, catalog, basis, unit) -> list | str: + """[시공, 토질, 구분, 규격, 단위, 수량] — 병합 줄은 앞 줄 시공을 이어받아 한 칸 되돌림.""" + width = len(table.get("condition_note") or []) + if width != 6: + return f"머리 {width} 칸" + lines: list[list[str]] = [] + for index, cells in enumerate(rows): + if index in judged["merged_rows"]: + if not lines or cells[-1]: + return f"{index}째 줄 병합 칸" + cells = [lines[-1][0], *cells[:-1]] + lines.append(cells) + methods = [cells[0] for cells in lines] + staged: list = [] + for index, cells in enumerate(lines): + if len(cells) != width: + return f"{index}째 줄 칸 {len(cells)}" + method, soil, name, size, _unit_word, value = cells + amount = parse_amount(value) + if not method or not name or amount is None: + return f"{index}째 줄 「{method}」 값 칸" + short = method.removesuffix("시공") + variant = " · ".join( + [judged["prefix"], short, *([soil] if methods.count(method) > 1 else [])] + ) + side = (size,) if size else () + forms = judged.get("form_split", {}).get(name) + for form in forms or (None,): + entry = _entry(catalog, f"{name}({form})" if form else name, code, side) + if entry is None: + # 그 갈래만 안 섬(자원이 하나뿐) — 사유는 못 맞춘 줄로. + label = f"{name} {size}".strip() + reason = unmatched_reason(catalog, name) + staged.append(UnmatchedRow(code, str(table.get("pum_table_id", "")), label, reason)) + continue + named = f"{variant} · {form}" if form else variant + staged.append(_row(code, table, entry, amount / basis, unit, index, named)) + return staged diff --git a/B09_Estimation/B09_Estimation_ResourceAxis_Sources.py b/B09_Estimation/B09_Estimation_ResourceAxis_Sources.py index c15d7c09..f696c0c6 100644 --- a/B09_Estimation/B09_Estimation_ResourceAxis_Sources.py +++ b/B09_Estimation/B09_Estimation_ResourceAxis_Sources.py @@ -168,7 +168,8 @@ def spec_candidates(cells: list[str]) -> list[str]: if spec: found.append(spec) continue - token = _RE_SIZE_TOKEN.fullmatch(text.rstrip("㎥㎡톤tonm³")) + # 「40.64㎝」(절단기 12-11-2) — 카탈로그는 수만 적음(7620-0003 절단기 40.64). + token = _RE_SIZE_TOKEN.fullmatch(text.rstrip("㎥㎡톤tonm³㎝")) if token: found.append(token.group(0)) return found diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 8d84d9f4..e97a7adb 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -31,12 +31,15 @@ from B09_Estimation.B09_Estimation_PriceBook import PriceBookError from B09_Estimation.B09_Estimation_Provenance import estimation_provenance from B09_Estimation.B09_Estimation_BillOfQuantities import bill_summary, build_bill from B09_Estimation.B09_Estimation_Guards import DoubleCountError +from B09_Estimation.B09_Estimation_MachineProductivity_Dump import ( + dump_distance_from_code, + dump_haul_distances, +) from B09_Estimation.B09_Estimation_Rates import RateLookupError from B09_Estimation.B09_Estimation_Statutory import STATUTORY_ITEMS from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at from B09_Estimation.B09_Estimation_UnitPrice import ( build_summary, - cached_build, detail_of, direct_cost_from_quantities, list_unit_prices, @@ -82,6 +85,8 @@ class CostRequest(BaseModel): estimated_price_krw: Decimal | None = None profit_adjustment_krw: Decimal = Field(default=Decimal(0), ge=0) waste_disposal_krw: Decimal = Field(default=Decimal(0), ge=0) + #: 폐기물 분리발주 — 기본 아님(경비로 원가에 듦). 켜면 총원가 밖. + waste_separate_order: bool = False environment_work_type: str = "civil_road" equipment_guarantee_work_type: str = "civil_general" @@ -116,6 +121,7 @@ class CostRequest(BaseModel): estimated_price_krw=self.estimated_price_krw, profit_adjustment_krw=self.profit_adjustment_krw, waste_disposal_krw=self.waste_disposal_krw, + waste_separate_order=self.waste_separate_order, environment_work_type=self.environment_work_type, equipment_guarantee_work_type=self.equipment_guarantee_work_type, subcontract_guarantee_variant=self.subcontract_guarantee_variant, @@ -246,11 +252,13 @@ async def _project_root_of(project_id: UUID) -> str | None: return None -async def _build_for(project_id: UUID): +async def _build_for(project_id: UUID, dump_haul_m: tuple[str, ...] = ()): """그 프로젝트가 **고른 값**으로 조립한 일위대가. ⚠ 범위 계수(작업효율)·장비 규격은 프로젝트마다 다를 수 있다(확정 ①). 전역 한 벌로 돌면 한 프로젝트에서 바꾼 값이 다른 프로젝트 금액까지 흔든다. + `dump_haul_m` — 덤프 운반 거리(m) 갈래. 인계(유토곡선·사토장)에서 오므로 내역이 넘기고, + 호표 한 장을 여는 쪽은 코드(`#L…m`)에서 되읽어 넘김. """ from B09_Estimation.B09_Estimation_LaborSurcharge import parse_choices as parse_labor_surcharge from common_util.common_util_project_settings import estimation_settings @@ -266,7 +274,11 @@ async def _build_for(project_id: UUID): # 공구손료·잡재료 — **비어 있는 것이 기본**이라 안 넣으면 줄이 안 선다(확정 5차 작은 것 1). # 유가 지역 — 안 고르면 전국평균(품셈 8-1-7 5호 「해당지역의 가격」). # 기계 수송비 — 거리·도로 구분이 있어야 선다(산림품셈 10-4 [주]). - return cached_build( + from B09_Estimation.B09_Estimation_Edits import edited_build, edits_key + from B09_Estimation.B09_Estimation_MaterialPrices import MATERIAL_PRICES_KEY + from B09_Estimation.B09_Estimation_MaterialPrices import build_key as material_prices_key + + args = ( ranges, machines, str(settings.get("misc_material_percent") or ""), @@ -277,7 +289,12 @@ async def _build_for(project_id: UUID): tuple(sorted(parse_labor_surcharge(settings.get("labor_surcharge")).items())), # 조종원 시간당 노임 자르는 자리 — 실무마다 다름(명세 7장 정정). 안 정하면 원 미만. str(settings.get("operator_wage_digits") or ""), + tuple(sorted(set(dump_haul_m), key=Decimal)), + # 자재 수동 단가(PLAN 1장 Ⓐ) — 코드 키만 조립에 얹음. 없으면 종전 벌 그대로. + material_prices_key(settings.get(MATERIAL_PRICES_KEY)), ) + # 사용자가 고친 값(PLAN 12장 2차) — 없으면 기본 조립 그 벌 그대로. + return edited_build(args, edits_key(settings.get("edits"))) @router.get("/{project_id}/estimation/base-data") @@ -403,317 +420,16 @@ async def get_design_doc_index(project_id: UUID) -> JSONResponse: ) -@router.get("/{project_id}/estimation/factors") -async def get_factor_choices(project_id: UUID) -> JSONResponse: - """**산출 조건** — 품셈이 범위로 준 계수와 장비 규격 (사용자 확정 ① 딸림 지시). - - 「값을 코드에 박고 끝내지 말 것 · 화면에 칸으로 세우고 근거를 보이고 바꿀 수 있게」 - 라는 지시대로, **지금 값 · 고를 수 있는 것 · 왜 그 값인지**를 함께 낸다. - """ - from B09_Estimation.B09_Estimation_FactorChoices import ( - BASIS_NOTES, - DEFAULT_CHOICE, - MACHINE_CHOICES, - MACHINE_OPTION_CODES, - machine_choices, - scan_range_factors, - ) - from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog - from B09_Estimation.B09_Estimation_UnitPrice import load_work_item_master - from common_util.common_util_project_settings import estimation_settings - - try: - root = await _project_root_of(project_id) - settings = estimation_settings(root) if root else {} - stored = settings.get("range_factor_choices") or {} - - ranges = [] - for item in scan_range_factors(load_work_item_master()): - choice = str(stored.get(item.key) or DEFAULT_CHOICE) - ranges.append( - { - "key": item.key, - "work_item_code": item.work_item_code, - "work_item_name": item.work_item_name, - "factor": item.factor, - "raw_cell": item.raw_cell, - "chosen": choice, - "value": str(item.value_of(choice)), - "is_default": choice == DEFAULT_CHOICE, - "options": item.options(), - "basis": BASIS_NOTES.get(item.key, []), - } - ) - - catalog = load_machine_catalog() - picked = machine_choices(settings) - machines = [] - for code, entry in MACHINE_CHOICES.items(): - options = [] - for machine_code in MACHINE_OPTION_CODES: - machine = catalog.machines.get(machine_code) - if machine is None: - continue - options.append( - { - "key": machine_code, - "label": f"{machine.name} {machine.specification}".strip(), - } - ) - machines.append( - { - "work_item_code": code, - "work_item_name": entry["work_item_name"], - "chosen": picked.get(code, entry["default_code"]), - "default": entry["default_code"], - "is_default": picked.get(code) == entry["default_code"], - "source": entry["source"], - "options": options, - "basis": entry["basis"], - } - ) - - from B09_Estimation.B09_Estimation_PriceBook import PriceKind - from B09_Estimation.B09_Estimation_UnitPrice import ( - MISC_MATERIAL_MAX_PERCENT, - MISC_MATERIAL_MIN_PERCENT, - ) - - from B09_Estimation.B09_Estimation_Transport import ASSUMPTION_TEXT as TRANSPORT_ASSUMPTION - from B09_Estimation.B09_Estimation_Transport import BASIS_TEXT as TRANSPORT_BASIS - from B09_Estimation.B09_Estimation_Transport import ROAD_CLASSES as TRANSPORT_ROADS - from B09_Estimation.B09_Estimation_Transport import TRANSPORT_VARIANTS - from B09_Estimation.B09_Estimation_Transport import WORK_ITEM_CODE as TRANSPORT_CODE - - # 「넣을 데가 있는가」 — 주재료비가 선 일위대가가 몇인지 세어 그대로 알린다. - # 지금은 사급 자재 단가가 미결(확정 5차 큰 것 8)이라 0 이 정상이다. - from B09_Estimation.B09_Estimation_LaborSurcharge import ( - COMBINE_NOTE as LABOR_SURCHARGE_COMBINE, - ) - from B09_Estimation.B09_Estimation_LaborSurcharge import SEAT_NOTE as LABOR_SURCHARGE_SEAT - from B09_Estimation.B09_Estimation_LaborSurcharge import load_series, parse_choices - from B09_Estimation.B09_Estimation_LaborSurcharge import total_percent - - LABOR_SURCHARGE_SCOPE = ( - "⚠ 26계열의 [주] 는 대개 조림·숲가꾸기·방제 작업을 지목합니다 — 임도 토공에 붙이라는" - " 지시가 원문에 없으므로, 각 계열의 [주] 를 보고 그 작업일 때만 고르십시오." - ) - labor_surcharge_series = load_series() - labor_surcharge_chosen = parse_choices(settings.get("labor_surcharge")) - labor_surcharge_total, labor_surcharge_reasons = total_percent(labor_surcharge_chosen) - - prices = await _build_for(project_id) - book = prices.book - transport_notes = list(prices.transport_notes) - transport_prices = {} - for variant in TRANSPORT_VARIANTS: - code = f"B-{TRANSPORT_CODE}#{variant['key']}" - if code in book.titles: - transport_prices[variant["key"]] = f"{book.resolve(code).total:,.0f}" - with_material = sum( - 1 - for unit_code, unit_title in book.titles.items() - if unit_title.kind is PriceKind.UNIT_PRICE and book.material_base(unit_code) > 0 - ) - - return JSONResponse( - content={ - "status": "success", - "ranges": ranges, - "machines": machines, - "misc_material": { - "percent": str(settings.get("misc_material_percent") or ""), - "min": str(MISC_MATERIAL_MIN_PERCENT), - "max": str(MISC_MATERIAL_MAX_PERCENT), - "basis": [ - "산림품셈 1-2-6 — 「각 항목에 명시되어 있지 않는 잡재료 및 소모재료 등을" - " 계상하고자 할 때에는 주재료비(재료비의 할증수량 제외)의 2~5%까지" - " 별도 계상하되 산정 근거를 명시하여야 한다」", - "⚠ 비워 두면 안 붙습니다 — 지금은 안 붙고 있는 상태입니다" - " (사용자 확정 2026-09-09 「지금은 안 넣되 숫자 넣으면 되게 열어 둘 것」).", - "⚠ 석공사·철골공사처럼 특수공구를 쓰는 자리는 건설품셈 1-2-6 이" - " 「별도 계상」으로 두었습니다 — 일반 비율을 그대로 붙이면 안 되는" - " 자리라, 그런 공종이 섞인 내역에서는 켜기 전에 살펴보십시오.", - ], - "base_items": with_material, - "base_note": ( - "" - if with_material - else "⚠ 지금은 일위대가에 주재료비가 선 공종이 하나도 없습니다" - " — 자재는 자재대 표에서 따로 금액이 섭니다. 값을 넣어도 붙을 밑수가" - " 없으므로, 사급 자재 단가가 서는 날 이 칸이 함께 살아납니다." - ), - }, - "transport": { - "distance_km": str(settings.get("transport_distance_km") or ""), - "road": str(settings.get("transport_road") or ""), - "roads": [ - {"key": row["key"], "label": row["label"]} for row in TRANSPORT_ROADS - ], - "variants": [ - { - "key": variant["key"], - "label": variant["label"], - "unit_price_krw": transport_prices.get(variant["key"], ""), - } - for variant in TRANSPORT_VARIANTS - ], - "basis": [TRANSPORT_BASIS, TRANSPORT_ASSUMPTION], - "notes": transport_notes, - }, - "labor_surcharge": { - "chosen": labor_surcharge_chosen, - "total_percent": f"{labor_surcharge_total:g}", - "reasons": labor_surcharge_reasons, - "series": [ - { - "key": item["key"], - "title": item["title"], - "section": item["section"], - "source_note": item["source_note"], - "options": [ - { - "key": option["key"], - "label": f"{option['label']} · {option['percent']:g}%", - } - for option in item["options"] - ], - } - for item in labor_surcharge_series - ], - "basis": [LABOR_SURCHARGE_SEAT, LABOR_SURCHARGE_COMBINE, LABOR_SURCHARGE_SCOPE], - }, - "notes": [ - "고를 수 있는 것은 원문에 적힌 값뿐입니다 — 그 밖의 수는 만들지 않습니다.", - "바꾸면 그 공종 단가가 바로 달라집니다. [저장]한 값은 이 프로젝트에만 걸립니다.", - ], - } - ) - except Exception: - logger.exception("B09 산출 조건 조회 실패: project_id=%s", project_id) - return JSONResponse( - status_code=500, - content={"status": "error", "message": "산출 조건을 못 불러왔습니다."}, - ) - - -class FactorChoiceBody(BaseModel): - """고른 값 — 안 보낸 칸은 그대로 둔다.""" - - range_factor_choices: dict[str, str] | None = None - machine_choices: dict[str, str] | None = None - #: 공구손료·잡재료 비율 — **빈 문자열이면 안 붙는다**(칸을 도로 비우는 길). - misc_material_percent: str | None = None - #: 유가 지역(시도코드) — **빈 문자열이면 전국평균**으로 돌아간다. - fuel_region: str | None = None - #: 기계 수송 거리(㎞)·도로 구분 — **비면 수송비 줄이 안 선다.** - transport_distance_km: str | None = None - transport_road: str | None = None - #: 품 할인·할증(1-4) — 계열코드 → 고른 행. **빈 값이면 그 계열을 끄는 것.** - labor_surcharge: dict[str, str] | None = None - - -@router.put("/{project_id}/estimation/factors") -async def put_factor_choices(project_id: UUID, body: FactorChoiceBody) -> JSONResponse: - """산출 조건을 이 프로젝트에 저장한다. **다른 구획은 손대지 않는다.**""" - from B09_Estimation.B09_Estimation_FactorChoices import CHOICE_KEYS, MACHINE_OPTION_CODES - from common_util.common_util_project_settings import save_section - - from common_util.common_util_project_settings import estimation_settings - - root = await _project_root_of(project_id) - if root is None: - return JSONResponse( - status_code=404, - content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."}, - ) - values: dict[str, Any] = {} - if body.range_factor_choices is not None: - # ⚠ 모르는 값은 안 받는다 — 원문에 없는 수가 설정으로 들어오면 그것이 임의 수치다. - values["range_factor_choices"] = { - str(key): str(value) - for key, value in body.range_factor_choices.items() - if str(value) in CHOICE_KEYS - } - if body.machine_choices is not None: - values["machine_choices"] = { - str(key): str(value) - for key, value in body.machine_choices.items() - if str(value) in MACHINE_OPTION_CODES - } - if body.misc_material_percent is not None: - from B09_Estimation.B09_Estimation_UnitPrice import parse_misc_material_percent - - try: - percent = parse_misc_material_percent(body.misc_material_percent) - except ValueError as exc: - # ⚠ 조용히 깎아 넣지 않는다 — 범위 밖 값을 상한으로 접으면 사용자가 넣은 값과 - # 금액이 어긋난 채로 선다. - return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) - values["misc_material_percent"] = "" if percent is None else str(percent) - if body.fuel_region is not None: - from B09_Estimation.B09_Estimation_MachineOperating import load_regional_fuel_table - - region = str(body.fuel_region).strip() - table, _ = load_regional_fuel_table() - if region and region not in table: - # ⚠ 판에 없는 지역을 받아 두면 조용히 전국평균으로 서고 사용자는 지역값인 줄 안다. - return JSONResponse( - status_code=400, - content={ - "status": "error", - "message": f"유가 판에 없는 지역입니다: {region}", - }, - ) - values["fuel_region"] = region - if body.transport_distance_km is not None: - from B09_Estimation.B09_Estimation_Transport import parse_distance_km - - try: - distance = parse_distance_km(body.transport_distance_km) - except ValueError as exc: - return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) - values["transport_distance_km"] = "" if distance is None else str(distance) - if body.transport_road is not None: - from B09_Estimation.B09_Estimation_Transport import road_class - - road = str(body.transport_road).strip() - if road and road_class(road) is None: - # ⚠ 원문 표에 없는 도로 구분을 받아 두면 속도를 못 골라 조용히 안 선다. - return JSONResponse( - status_code=400, - content={"status": "error", "message": f"원문에 없는 도로 구분입니다: {road}"}, - ) - values["transport_road"] = road - if body.labor_surcharge is not None: - from B09_Estimation.B09_Estimation_LaborSurcharge import parse_choices - - # ⚠ **원문에 있는 선택지만** 받는다 — 없는 율이 설정으로 들어오면 그것이 임의 수치다. - stored = dict(estimation_settings(root).get("labor_surcharge") or {}) - for series_key, option_key in body.labor_surcharge.items(): - if str(option_key).strip(): - stored[str(series_key)] = str(option_key) - else: - stored.pop(str(series_key), None) # 빈 값 = 그 계열 끄기 - values["labor_surcharge"] = parse_choices(stored) - try: - save_section(root, "estimation", values, replace_keys=tuple(values)) - return JSONResponse(content={"status": "success", **values}) - except Exception: - logger.exception("B09 산출 조건 저장 실패: project_id=%s", project_id) - return JSONResponse( - status_code=500, - content={"status": "error", "message": "산출 조건을 저장하지 못했습니다."}, - ) - - @router.get("/{project_id}/estimation/unit-prices/{code}") async def get_unit_price_detail(project_id: UUID, code: str) -> JSONResponse: """일위대가 **본표** — 「무엇으로 이루어졌나」. 줄마다 원천·파고들기 표시가 붙는다.""" try: return JSONResponse( content=_with_provenance( - {"status": "success", **detail_of(await _build_for(project_id), code)} + { + "status": "success", + **detail_of(await _build_for(project_id, dump_distance_from_code(code)), code), + } ) ) except PriceBookError as error: @@ -758,11 +474,12 @@ async def get_bill(project_id: UUID) -> JSONResponse: from B08_Quantity.B08_Quantity_Router_Material import get_handoff, structure_bill_prices try: - # ⚠ **그 프로젝트가 고른 값**으로 조립한 단가표 — 구조물도 미리보기(`_build_for`)와 한 벌. - # 기본값(`cached_build()`)을 쓰면 계수를 고른 프로젝트에서 화면과 내역이 갈림(브레인). - build = await _build_for(project_id) response = await get_handoff(project_id) payload = json.loads(bytes(response.body).decode("utf-8")) + # ⚠ **그 프로젝트가 고른 값**으로 조립한 단가표 — 구조물도 미리보기(`_build_for`)와 한 벌. + # 기본값(`cached_build()`)을 쓰면 계수를 고른 프로젝트에서 화면과 내역이 갈림(브레인). + # 덤프 운반은 인계 거리마다 갈래가 서므로 인계를 먼저 받음(10-12 · 2026-09-14). + build = await _build_for(project_id, dump_haul_distances(payload)) except Exception: logger.exception("B09 내역서 조회 실패(인계): project_id=%s", project_id) return JSONResponse( @@ -781,7 +498,20 @@ async def get_bill(project_id: UUID) -> JSONResponse: structure_prices = {} try: - result = build_bill(payload, build=build, structure_prices=structure_prices) + # 할증율·일괄보정(사용자 · 12장 2차 ④) — 프로젝트 고친 값에서. + from B09_Estimation.B09_Estimation_Edits import normalize + from common_util.common_util_project_settings import estimation_settings + + root = await _project_root_of(project_id) + stored = estimation_settings(root) if root else {} + rates = normalize(stored.get("edits")).get("bill_rates") + result = build_bill( + payload, + build=build, + structure_prices=structure_prices, + rates=rates, + material_prices=stored.get("material_prices"), + ) except DoubleCountError as error: # 이중계상 감시에 걸린 경우 — 표를 그리지 않고 멈춘다. logger.warning("B09 내역서 이중계상 감지: project_id=%s, %s", project_id, error) @@ -793,10 +523,14 @@ async def get_bill(project_id: UUID) -> JSONResponse: content={"status": "error", "message": "예산내역서를 세우지 못했습니다."}, ) + from B09_Estimation.B09_Estimation_Lists import bill_sheets + return JSONResponse( content=_with_provenance( { "status": "success", + # 집계표 넷·목록표 — 이 내역이 쓴 자원을 처음 쓰인 차례로 되모음(값은 새로 안 셈). + **bill_sheets(result, build), "rows": [row.as_dict() for row in result.rows], "excluded": [row.as_dict() for row in result.excluded], "materials": [row.as_dict() for row in result.material_rows], @@ -804,6 +538,11 @@ async def get_bill(project_id: UUID) -> JSONResponse: "price_basis": ( result.price_basis.as_dict() if result.price_basis else {"entries": []} ), + "unit_price_sheet": ( + result.unit_price_sheet.as_dict() + if result.unit_price_sheet + else {"entries": []} + ), } ) ) @@ -816,7 +555,7 @@ async def get_price_basis_detail(project_id: UUID, code: str) -> JSONResponse: try: # 내역서와 같은 단가표 — 번호·금액이 내역 줄과 갈리지 않게. - body = price_basis_detail(code, await _build_for(project_id)) + body = price_basis_detail(code, await _build_for(project_id, dump_distance_from_code(code))) except Exception: logger.exception("B09 단가산출서 조회 실패: project_id=%s, code=%s", project_id, code) return JSONResponse( diff --git a/B09_Estimation/B09_Estimation_Router_Contract.py b/B09_Estimation/B09_Estimation_Router_Contract.py new file mode 100644 index 00000000..210cab13 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Router_Contract.py @@ -0,0 +1,110 @@ +"""B09 계약 단계 탭 API — 당초설계 → 계약내역 (PLAN 12장 · 랩탑 메인). + +⚠ 설계 내역을 **읽기만** 한다(`/estimation/bill` 결과를 복사해 적용률을 얹음). 저장은 + `estimation.contract` 한 칸 — 설계 내역·원가계산서 저장본은 안 건드린다. +""" + +from __future__ import annotations + +import json +import logging +from typing import Any +from uuid import UUID + +from fastapi import APIRouter +from fastapi.responses import JSONResponse + +from B09_Estimation.B09_Estimation_Contract import ( + OPTIONS, + RATE_FIELDS, + SETTINGS_KEY, + clean_settings, + contract_bill, +) + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["B09 Estimation — Contract"]) + + +async def _root(project_id: UUID) -> str | None: + from B09_Estimation.B09_Estimation_Router import _project_root_of + + return await _project_root_of(project_id) + + +async def contract_for( + project_id: UUID, +) -> tuple[str, dict[str, Any], dict[str, Any], dict[str, Any]] | JSONResponse: + """(저장 폴더, 설계 내역, 계약 조건, 계약내역) — 못 서면 그 까닭 응답. + + 기성도 이 길로 계약본을 받음(계약 값을 따로 셈하지 않음). + """ + from B09_Estimation.B09_Estimation_Router import _build_for, get_bill + from common_util.common_util_project_settings import estimation_settings + + root = await _root(project_id) + if root is None: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."}, + ) + response = await get_bill(project_id) + bill = json.loads(bytes(response.body).decode("utf-8")) + if response.status_code != 200 or "rows" not in bill: + return JSONResponse(status_code=response.status_code or 502, content=bill) + stored, _ = clean_settings(dict(estimation_settings(root).get(SETTINGS_KEY) or {})) + # 조립본은 캐시 공유본 — `contract_bill` 이 단가표 복사본에만 적용률을 얹음(설계 불변). + build = await _build_for(project_id) if stored.get("apply_to_base_prices") else None + return root, bill, stored, contract_bill(bill["rows"], stored, build=build) + + +@router.get("/{project_id}/estimation/contract") +async def get_contract(project_id: UUID) -> JSONResponse: + """계약내역 한 장 — 설계 줄 옆에 계약단가·계약금액 · 설계↔계약 합계 · 옵션.""" + found = await contract_for(project_id) + if isinstance(found, JSONResponse): + return found + _, bill, stored, result = found + return JSONResponse( + content={ + "status": "success", + **result, + "settings": stored, + "fields": { + "rates": [{"key": k, "label": label} for k, label in RATE_FIELDS], + "options": [{"key": k, "label": label} for k, label in OPTIONS], + }, + "bill_missing_count": len((bill.get("summary") or {}).get("missing") or []), + "limit_note": ( + "계약 표본 0건 — 구조가 서는지까지만 확인됨 · 원 단위 값은 표본이 생기면 대조" + ), + } + ) + + +@router.put("/{project_id}/estimation/contract") +async def put_contract(project_id: UUID, body: dict[str, Any]) -> JSONResponse: + """적용률·옵션·적용 제외 줄 저장 — 틀린 칸이 있으면 아무것도 안 저장.""" + from common_util.common_util_project_settings import save_section + + root = await _root(project_id) + if root is None: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."}, + ) + cleaned, errors = clean_settings(body) + if errors: + return JSONResponse( + status_code=400, + content={"status": "error", "message": " · ".join(errors), "errors": errors}, + ) + try: + save_section(root, "estimation", {SETTINGS_KEY: cleaned}, replace_keys=(SETTINGS_KEY,)) + except Exception: + logger.exception("B09 계약 조건 저장 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "계약 조건을 저장하지 못했습니다."}, + ) + return JSONResponse(content={"status": "success", "settings": cleaned}) diff --git a/B09_Estimation/B09_Estimation_Router_CostSheet.py b/B09_Estimation/B09_Estimation_Router_CostSheet.py new file mode 100644 index 00000000..ac85aa7c --- /dev/null +++ b/B09_Estimation/B09_Estimation_Router_CostSheet.py @@ -0,0 +1,275 @@ +"""B09 원가계산서 · 제비율 요율표 탭 API (PLAN 12장 · 랩탑 메인). + +⚠ 옛 `/estimation/cost`(수기 입력)는 그대로 둔다 — 새 탭은 **프로젝트 값**으로 선다: + 직접비 = 예산내역서(`/estimation/bill`) 합계 · 기준 입력 = `estimation.cost_sheet` 저장값 · + 폐기물처리비 = B08 「임목폐기물 처리」 톤 × 수동 처리단가(분리발주 여부도 B08 산출 조건). +⚠ 화면이 값을 만들지 않는다 — 서식 줄·금액·산식은 전부 여기서 낸다. +""" + +from __future__ import annotations + +import json +import logging +import math +from decimal import Decimal +from typing import Any +from uuid import UUID + +from fastapi import APIRouter +from fastapi.responses import JSONResponse + +from B09_Estimation.B09_Estimation_CostSheet import ( + AMOUNT_FIELDS, + CHOICE_FIELDS, + FIELD_HINTS, + SETTINGS_KEY, + WORK_TYPE_FIELDS, + clean_settings, + cost_input, + field_options, + sheet_rows, + status_line, +) +from B09_Estimation.B09_Estimation_CostSheet_Forms import FORM_LABELS, form_rows +from B09_Estimation.B09_Estimation_Engine_Cost import calculate_cost +from B09_Estimation.B09_Estimation_RateOverride import apply_overrides, clean_overrides +from B09_Estimation.B09_Estimation_Rates import RateLookupError, load_rate_dataset +from B09_Estimation.B09_Estimation_RateTable import rate_sections +from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["B09 Estimation — Cost Sheet"]) + +TREE_WASTE_ITEM = "임목폐기물 처리" + + +async def _root(project_id: UUID) -> str | None: + from B09_Estimation.B09_Estimation_Router import _project_root_of + + return await _project_root_of(project_id) + + +def _waste(bill: dict[str, Any], quantity: dict[str, Any]) -> tuple[Decimal, bool, str]: + """(금액, 분리발주, 사유) — 톤은 내역서 제외 줄에서, 단가는 B08 수동 입력에서.""" + tons = next( + ( + Decimal(str(row.get("quantity") or 0)) + for row in bill.get("excluded") or [] + if row.get("name") == TREE_WASTE_ITEM + ), + Decimal(0), + ) + price = quantity.get("tree_waste_unit_price_krw_per_ton") + separate = bool(quantity.get("waste_separate_order")) + if tons <= 0: + return Decimal(0), separate, "임목폐기물 톤이 아직 안 섬(수량산출 산출 조건)" + if not price: + return Decimal(0), separate, f"임목폐기물 {tons:,.2f}톤 — 처리단가 없음(수동 입력 전)" + amount = Decimal(math.floor(tons * Decimal(str(price)))) + return amount, separate, f"임목폐기물 {tons:,.2f}톤 × {price:,.0f}원 — ⚠ 수동 단가(미확정)" + + +def cost_from_bill(root: str, bill: dict[str, Any], direct: dict[str, Decimal]): + """(엔진 입력, 원가계산 결과, 폐기물 사유) — 직접비만 갈아 끼우면 계약·기성도 같은 길로 섬. + + 기준 입력·요율 덮어쓰기·폐기물 톤은 프로젝트 저장값 그대로. `RateLookupError` 는 부른 쪽이 받음. + """ + from common_util.common_util_project_settings import estimation_settings, quantity_settings + + stored = dict(estimation_settings(root).get(SETTINGS_KEY) or {}) + waste, separate, waste_note = _waste(bill, quantity_settings(root)) + overrides = tuple(estimation_settings(root).get(OVERRIDES_KEY) or ()) + data = cost_input(direct, stored, waste, separate, overrides) + return data, calculate_cost(data), waste_note + + +@router.get("/{project_id}/estimation/cost-sheet") +async def get_cost_sheet(project_id: UUID, form: str = "general") -> JSONResponse: + """원가계산서 한 장 — 서식 줄 · 기준 입력 선택지 · 상태줄. + + `form` — 일반형식만 금액을 냄. 수공·실적일반·실적수공은 **서식 차례만**(요율표 미확보 줄은 + 막고 사유 · 0 원으로 안 채움). + """ + from B09_Estimation.B09_Estimation_Router import get_bill + from common_util.common_util_project_settings import estimation_settings + + root = await _root(project_id) + if root is None: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."}, + ) + response = await get_bill(project_id) + bill = json.loads(bytes(response.body).decode("utf-8")) + if response.status_code != 200 or "summary" not in bill: + # 내역서가 안 서면 원가계산서도 못 섬 — 사유를 그대로 넘긴다. + return JSONResponse(status_code=response.status_code or 502, content=bill) + summary = bill["summary"] + place = OutputPlace.RESOURCE_SUMMARY + direct = { + part: round_at(Decimal(str(summary.get(f"direct_{part}_krw") or 0)), place) + for part in ("material", "labor", "expense") + } + stored = dict(estimation_settings(root).get(SETTINGS_KEY) or {}) + overrides = tuple(estimation_settings(root).get(OVERRIDES_KEY) or ()) + try: + data, result, waste_note = cost_from_bill(root, bill, direct) + dataset = load_rate_dataset(data.rate_file_name) + except RateLookupError as error: + return JSONResponse(status_code=422, content={"status": "error", "message": str(error)}) + return JSONResponse( + content={ + "status": "success", + "form": "일반", + "rows": sheet_rows(result, data), + "status_line": status_line(result, data), + "settings": { + **{key: str(getattr(data, key)) for key, _ in (*WORK_TYPE_FIELDS, *CHOICE_FIELDS)}, + **{key: str(getattr(data, key)) for key, _ in AMOUNT_FIELDS}, + "duration_days": data.duration_days, + }, + "stored": stored, + "fields": { + "work_types": [{"key": k, "label": label} for k, label in WORK_TYPE_FIELDS], + "choices": [{"key": k, "label": label} for k, label in CHOICE_FIELDS], + "hints": FIELD_HINTS, + "amounts": [{"key": k, "label": label} for k, label in AMOUNT_FIELDS], + "options": field_options(dataset), + }, + "waste_note": waste_note, + "bill_missing_count": len(summary.get("missing") or []), + "bill_unconfirmed_count": int(summary.get("unconfirmed_count") or 0), + "rate_version": result.rate_version, + "notes": result.notes, + "rate_override_count": len(overrides), + "form_key": "general", + "form_labels": FORM_LABELS, + **(_other_form(form) if form != "general" else {}), + } + ) + + +@router.put("/{project_id}/estimation/cost-sheet") +async def put_cost_sheet(project_id: UUID, body: dict[str, Any]) -> JSONResponse: + """기준 입력 저장 — `estimation.cost_sheet` 한 칸만 통째로 갈아 끼움.""" + from common_util.common_util_project_settings import save_section + + root = await _root(project_id) + if root is None: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."}, + ) + cleaned = clean_settings(body, load_rate_dataset()) + try: + save_section(root, "estimation", {SETTINGS_KEY: cleaned}, replace_keys=(SETTINGS_KEY,)) + except Exception: + logger.exception("B09 원가계산서 기준 저장 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "원가계산서 기준을 저장하지 못했습니다."}, + ) + return JSONResponse(content={"status": "success", "stored": cleaned}) + + +#: 프로젝트 요율 덮어쓰기 저장 자리 — `estimation` 구획 안 한 칸(마스터 요율 파일은 안 건드림). +OVERRIDES_KEY = "rate_overrides" + + +def _other_form(form: str) -> dict[str, Any]: + """나머지 세 형식 — 금액 없는 서식 줄로 갈아 끼움(기준 입력판 자료는 그대로).""" + if form not in FORM_LABELS: + return {"form_error": f"모르는 형식입니다: {form}"} + return { + "form_key": form, + "form": FORM_LABELS[form], + "rows": form_rows(form), + "status_line": {}, + "form_note": ( + "금액 미산출 — 이 형식의 요율표(수공 세부 경비·표준시장단가 제비율)가 없어" + " 서식 차례만 보임" + ), + } + + +@router.get("/{project_id}/estimation/rate-table") +async def get_rate_table(project_id: UUID) -> JSONResponse: + """제비율 요율표 — 마스터 요율에 **이 프로젝트의 덮어쓰기**를 얹어 DFM 첫 탭 차례로. + + 칸마다 기본값(마스터)·지금 값·주소를 실어 화면이 갈라 보이고 고칠 수 있게 한다. + """ + from common_util.common_util_project_settings import estimation_settings + + root = await _root(project_id) + master = load_rate_dataset() + overrides = list((estimation_settings(root) if root else {}).get(OVERRIDES_KEY) or []) + try: + current = apply_overrides(master, overrides) + except RateLookupError as error: + # 판이 바뀌어 주소가 안 맞는 덮어쓰기 — 조용히 버리지 않고 알림(마스터 값으로 보임). + return JSONResponse( + content={ + "status": "success", + "rate_version": master.version_stamp, + "sections": rate_sections(master), + "overrides": overrides, + "override_error": str(error), + } + ) + return JSONResponse( + content={ + "status": "success", + "rate_version": master.version_stamp, + "sections": rate_sections(current, master), + "overrides": overrides, + "override_error": "", + } + ) + + +@router.put("/{project_id}/estimation/rate-table") +async def put_rate_table(project_id: UUID, body: dict[str, Any]) -> JSONResponse: + """덮어쓰기 저장 — 목록을 **통째로** 갈아 끼움(빈 목록 = 전부 기본값으로 되돌림). + + ⚠ 한 줄이라도 사유가 비거나 주소가 틀리면 **아무것도 안 저장**하고 까닭을 돌려줌. + """ + from datetime import datetime + + from common_util.common_util_project_settings import estimation_settings, save_section + + root = await _root(project_id) + if root is None: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."}, + ) + kept, errors = clean_overrides(body.get("overrides"), load_rate_dataset()) + if errors: + return JSONResponse( + status_code=400, + content={"status": "error", "message": " · ".join(errors), "errors": errors}, + ) + previous = { + (o.get("variable"), o.get("table"), str(sorted((o.get("match") or {}).items()))): o + for o in estimation_settings(root).get(OVERRIDES_KEY) or [] + } + now = datetime.now().isoformat(timespec="seconds") + for entry in kept: + # 값·사유가 그대로면 입력 시각을 지킴 — 「언제 고쳤나」가 저장할 때마다 바뀌지 않게. + before = previous.get( + (entry["variable"], entry["table"], str(sorted(entry["match"].items()))) + ) + same = before and (before.get("rate_percent"), before.get("reason")) == ( + entry["rate_percent"], + entry["reason"], + ) + entry["entered_at"] = before.get("entered_at") if same else now + try: + save_section(root, "estimation", {OVERRIDES_KEY: kept}, replace_keys=(OVERRIDES_KEY,)) + except Exception: + logger.exception("B09 요율 덮어쓰기 저장 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "요율 덮어쓰기를 저장하지 못했습니다."}, + ) + return JSONResponse(content={"status": "success", "overrides": kept}) diff --git a/B09_Estimation/B09_Estimation_Router_Edits.py b/B09_Estimation/B09_Estimation_Router_Edits.py new file mode 100644 index 00000000..344293d5 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Router_Edits.py @@ -0,0 +1,149 @@ +"""B09 원가계산 라우터 — **사용자가 고친 값** 읽기·저장 (PLAN 12장 2차 · `B09_Estimation_Edits`). + +⚠ `B09_Estimation_Router.py` 는 쪼개기 차례를 기다리는 중이라(브레인 판정) 새 문을 여기 둠. +⚠ 저장은 고친 값만 — 금액은 다음 조회 때 서버가 조립 뒤 고친 값을 얹어 **다시 계산**함. +""" + +from __future__ import annotations + +import logging +from typing import Any +from uuid import UUID + +from fastapi import APIRouter +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +from B09_Estimation.B09_Estimation_Edits import ( + EDITS_KEY, + EditError, + merge_changes, + normalize, +) + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["B09 Estimation Edits"]) + + +class EditChange(BaseModel): + """고칠 칸 하나 — `value` 가 `null` 이면 그 칸을 지움(↺ 계산값으로 돌아감).""" + + section: str + key: str + value: Any = None + + +class EditRequest(BaseModel): + changes: list[EditChange] = Field(default_factory=list) + + +@router.get("/{project_id}/estimation/edits") +async def get_edits(project_id: UUID) -> JSONResponse: + """고친 값 한 벌 — 화면이 「사용자」 표시·↺ 를 붙이는 데 씀.""" + from B09_Estimation.B09_Estimation_Router import _build_for, _project_root_of + from common_util.common_util_project_settings import estimation_settings + + root = await _project_root_of(project_id) + settings = estimation_settings(root) if root else {} + build = await _build_for(project_id) + return JSONResponse( + content={ + "status": "success", + EDITS_KEY: normalize(settings.get(EDITS_KEY)), + "skipped": list(getattr(build, "edit_skipped", [])), + } + ) + + +@router.get("/{project_id}/estimation/edits/sheet/{code}") +async def get_edit_sheet(project_id: UUID, code: str) -> JSONResponse: + """호표 본표 + 줄마다 고친 값 표시(`edit`) — 일위대가·단산 구성행 수정·Q 식 편집 화면이 씀. + + 본표 모양은 `/unit-prices/{code}` 와 같음(`detail_of`) — 줄 차례가 상세 줄 차례와 같아 칸이 맞음. + """ + from B09_Estimation.B09_Estimation_Edits import EDITABLE_KINDS, row_edits + from B09_Estimation.B09_Estimation_PriceBook import PriceBookError + from B09_Estimation.B09_Estimation_Router import _build_for, _with_provenance + from B09_Estimation.B09_Estimation_UnitPrice import detail_of + + try: + build = await _build_for(project_id) + body = detail_of(build, code) + except PriceBookError as error: + return JSONResponse(status_code=404, content={"status": "error", "message": str(error)}) + except Exception: + logger.exception("B09 편집 본표 실패: project_id=%s, code=%s", project_id, code) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "본표를 못 만들었습니다."}, + ) + for row, edit in zip(body["rows"], row_edits(build, code)): + row["edit"] = edit + added = [ + int(mark["key"].rsplit("|+", 1)[1]) + for mark in getattr(build, "edit_rows", {}).get(code, {}).values() + if "|+" in str(mark.get("key", "")) + ] + body["editable"] = build.book.title(code).kind in EDITABLE_KINDS + body["next_add_key"] = f"{code}|+{max(added, default=0) + 1}" + return JSONResponse(content=_with_provenance({"status": "success", **body})) + + +@router.get("/{project_id}/estimation/edits/search") +async def search_titles(project_id: UUID, q: str = "", parent: str = "") -> JSONResponse: + """줄 더하기 고르개 — 단가표 제목을 이름·규격으로 찾음(노임·자재·중기·일위대가·단산·일식).""" + from B09_Estimation.B09_Estimation_Edits import ADDABLE_KINDS + from B09_Estimation.B09_Estimation_Router import _build_for + from B09_Estimation.B09_Estimation_UnitPrice import HOURLY_WAGE_SUFFIX, SOURCE_LABEL + + words = [word for word in q.lower().split() if word] + build = await _build_for(project_id) + rows = [] + for code, title in build.book.titles.items(): + if title.kind not in ADDABLE_KINDS or code == parent or code.endswith(HOURLY_WAGE_SUFFIX): + continue + text = f"{title.name} {title.spec} {code}".lower() + if words and all(word in text for word in words): + rows.append( + { + "code": code, + "name": title.name, + "spec": title.spec, + "unit": title.unit, + "kind_label": SOURCE_LABEL.get(title.kind, ""), + } + ) + if len(rows) >= 40: + break + return JSONResponse(content={"status": "success", "rows": rows}) + + +@router.put("/{project_id}/estimation/edits") +async def put_edits(project_id: UUID, payload: EditRequest) -> JSONResponse: + """고친 값 저장 — 칸마다 검사하고(값 없는 슬롯 등은 받지 않음) 구획째 갈아 끼움.""" + from B09_Estimation.B09_Estimation_Router import _build_for, _project_root_of + from common_util.common_util_project_settings import estimation_settings, save_section + + root = await _project_root_of(project_id) + if root is None: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."}, + ) + settings = estimation_settings(root) + try: + build = await _build_for(project_id) + edits = merge_changes( + settings.get(EDITS_KEY), build, [change.model_dump() for change in payload.changes] + ) + except EditError as error: + return JSONResponse(status_code=422, content={"status": "error", "message": str(error)}) + try: + save_section(root, "estimation", {EDITS_KEY: edits}, replace_keys=(EDITS_KEY,)) + except Exception: + logger.exception("B09 고친 값 저장 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "고친 값을 저장하지 못했습니다."}, + ) + return JSONResponse(content={"status": "success", EDITS_KEY: edits}) diff --git a/B09_Estimation/B09_Estimation_Router_Execution.py b/B09_Estimation/B09_Estimation_Router_Execution.py new file mode 100644 index 00000000..3f4341f1 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Router_Execution.py @@ -0,0 +1,96 @@ +"""B09 실행예산 단계 탭 API — 설계 내역 → 실행예산 (PLAN 12장 · 랩탑 메인). + +⚠ 설계 내역·단가표를 **읽기만** 한다(복사본에 중기 실행단가·실행수량을 얹음). 저장은 + `estimation.execution` 한 칸 — 설계 내역·계약·원가계산서 저장본은 안 건드린다. +""" + +from __future__ import annotations + +import json +import logging +from typing import Any +from uuid import UUID + +from fastapi import APIRouter +from fastapi.responses import JSONResponse + +from B09_Estimation.B09_Estimation_Execution import ( + CORRECTION_HINTS, + CORRECTIONS, + CUT_UNITS, + SETTINGS_KEY, + clean_settings, + execution_bill, +) + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["B09 Estimation — Execution"]) + +_NOT_FOUND = {"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."} + + +async def _root(project_id: UUID) -> str | None: + from B09_Estimation.B09_Estimation_Router import _project_root_of + + return await _project_root_of(project_id) + + +@router.get("/{project_id}/estimation/execution") +async def get_execution(project_id: UUID) -> JSONResponse: + """실행예산 한 장 — 설계 줄 옆에 실행수량·실행단가·실행금액 · 중기 실행단가 표 · 합계.""" + from B09_Estimation.B09_Estimation_Router import _build_for, get_bill + from common_util.common_util_project_settings import estimation_settings + + root = await _root(project_id) + if root is None: + return JSONResponse(status_code=404, content=_NOT_FOUND) + response = await get_bill(project_id) + bill = json.loads(bytes(response.body).decode("utf-8")) + if response.status_code != 200 or "rows" not in bill: + return JSONResponse(status_code=response.status_code or 502, content=bill) + stored, _ = clean_settings(dict(estimation_settings(root).get(SETTINGS_KEY) or {})) + # 조립본은 캐시 공유본 — `execution_bill` 이 단가표 복사본에만 중기 실행단가를 얹음(설계 불변). + result = execution_bill(bill["rows"], stored, build=await _build_for(project_id)) + return JSONResponse( + content={ + "status": "success", + **result, + "settings": stored, + "fields": { + "cut_units": list(CUT_UNITS), + "corrections": [ + {"key": k, "label": label, "hint": CORRECTION_HINTS[k]} + for k, label in CORRECTIONS + ], + }, + "bill_missing_count": len((bill.get("summary") or {}).get("missing") or []), + "limit_note": ( + "실행예산 표본 0건 — 구조가 서는지까지만 확인됨 · 성분 가르기·기본보정은 확인 대기" + ), + } + ) + + +@router.put("/{project_id}/estimation/execution") +async def put_execution(project_id: UUID, body: dict[str, Any]) -> JSONResponse: + """중기 실행단가 입력·실행수량 저장 — 틀린 칸이 있으면 아무것도 안 저장.""" + from common_util.common_util_project_settings import save_section + + root = await _root(project_id) + if root is None: + return JSONResponse(status_code=404, content=_NOT_FOUND) + cleaned, errors = clean_settings(body) + if errors: + return JSONResponse( + status_code=400, + content={"status": "error", "message": " · ".join(errors), "errors": errors}, + ) + try: + save_section(root, "estimation", {SETTINGS_KEY: cleaned}, replace_keys=(SETTINGS_KEY,)) + except Exception: + logger.exception("B09 실행예산 저장 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "실행예산을 저장하지 못했습니다."}, + ) + return JSONResponse(content={"status": "success", "settings": cleaned}) diff --git a/B09_Estimation/B09_Estimation_Router_Factors.py b/B09_Estimation/B09_Estimation_Router_Factors.py new file mode 100644 index 00000000..bc11c3b8 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Router_Factors.py @@ -0,0 +1,334 @@ +"""B09 원가계산 라우터 — **산출 조건**(품셈이 범위로 준 계수·장비 규격·공구손료·수송·품 할증·유가 지역) 읽기·저장. + +`B09_Estimation_Router.py` 가 700줄을 넘어(지침 4장) 떼어 냄(2026-09-14 브레인 차례). 문 이름·응답 모양은 그대로. +⚠ 프로젝트 폴더·조립은 본 라우터 것을 **부를 때마다** 빌려 씀 — 시험이 본 라우터의 `_project_root_of` 를 바꿔 끼움. +""" + +from __future__ import annotations + +import logging +from typing import Any +from uuid import UUID + +from fastapi import APIRouter +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"]) + + +async def _project_root_of(project_id: UUID) -> str | None: + from B09_Estimation import B09_Estimation_Router as base + + return await base._project_root_of(project_id) + + +async def _build_for(project_id: UUID): + from B09_Estimation import B09_Estimation_Router as base + + return await base._build_for(project_id) + + +@router.get("/{project_id}/estimation/factors") +async def get_factor_choices(project_id: UUID) -> JSONResponse: + """**산출 조건** — 품셈이 범위로 준 계수와 장비 규격 (사용자 확정 ① 딸림 지시). + + 「값을 코드에 박고 끝내지 말 것 · 화면에 칸으로 세우고 근거를 보이고 바꿀 수 있게」 + 라는 지시대로, **지금 값 · 고를 수 있는 것 · 왜 그 값인지**를 함께 낸다. + """ + from B09_Estimation.B09_Estimation_FactorChoices import ( + BASIS_NOTES, + DEFAULT_CHOICE, + MACHINE_CHOICES, + MACHINE_OPTION_CODES, + machine_choices, + scan_range_factors, + ) + from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog + from B09_Estimation.B09_Estimation_UnitPrice import load_work_item_master + from common_util.common_util_project_settings import estimation_settings + + try: + root = await _project_root_of(project_id) + settings = estimation_settings(root) if root else {} + stored = settings.get("range_factor_choices") or {} + + ranges = [] + for item in scan_range_factors(load_work_item_master()): + choice = str(stored.get(item.key) or DEFAULT_CHOICE) + ranges.append( + { + "key": item.key, + "work_item_code": item.work_item_code, + "work_item_name": item.work_item_name, + "factor": item.factor, + "raw_cell": item.raw_cell, + "chosen": choice, + "value": str(item.value_of(choice)), + "is_default": choice == DEFAULT_CHOICE, + "options": item.options(), + "basis": BASIS_NOTES.get(item.key, []), + } + ) + + catalog = load_machine_catalog() + picked = machine_choices(settings) + machines = [] + for code, entry in MACHINE_CHOICES.items(): + options = [] + for machine_code in MACHINE_OPTION_CODES: + machine = catalog.machines.get(machine_code) + if machine is None: + continue + options.append( + { + "key": machine_code, + "label": f"{machine.name} {machine.specification}".strip(), + } + ) + machines.append( + { + "work_item_code": code, + "work_item_name": entry["work_item_name"], + "chosen": picked.get(code, entry["default_code"]), + "default": entry["default_code"], + "is_default": picked.get(code) == entry["default_code"], + "source": entry["source"], + "options": options, + "basis": entry["basis"], + } + ) + + from B09_Estimation.B09_Estimation_PriceBook import PriceKind + from B09_Estimation.B09_Estimation_UnitPrice import ( + MISC_MATERIAL_MAX_PERCENT, + MISC_MATERIAL_MIN_PERCENT, + ) + + from B09_Estimation.B09_Estimation_Transport import ASSUMPTION_TEXT as TRANSPORT_ASSUMPTION + from B09_Estimation.B09_Estimation_Transport import BASIS_TEXT as TRANSPORT_BASIS + from B09_Estimation.B09_Estimation_Transport import ROAD_CLASSES as TRANSPORT_ROADS + from B09_Estimation.B09_Estimation_Transport import TRANSPORT_VARIANTS + from B09_Estimation.B09_Estimation_Transport import WORK_ITEM_CODE as TRANSPORT_CODE + + # 「넣을 데가 있는가」 — 주재료비가 선 일위대가가 몇인지 세어 그대로 알린다. + # 지금은 사급 자재 단가가 미결(확정 5차 큰 것 8)이라 0 이 정상이다. + from B09_Estimation.B09_Estimation_LaborSurcharge import ( + COMBINE_NOTE as LABOR_SURCHARGE_COMBINE, + ) + from B09_Estimation.B09_Estimation_LaborSurcharge import SEAT_NOTE as LABOR_SURCHARGE_SEAT + from B09_Estimation.B09_Estimation_LaborSurcharge import load_series, parse_choices + from B09_Estimation.B09_Estimation_LaborSurcharge import total_percent + + LABOR_SURCHARGE_SCOPE = ( + "⚠ 26계열의 [주] 는 대개 조림·숲가꾸기·방제 작업을 지목합니다 — 임도 토공에 붙이라는" + " 지시가 원문에 없으므로, 각 계열의 [주] 를 보고 그 작업일 때만 고르십시오." + ) + labor_surcharge_series = load_series() + labor_surcharge_chosen = parse_choices(settings.get("labor_surcharge")) + labor_surcharge_total, labor_surcharge_reasons = total_percent(labor_surcharge_chosen) + + prices = await _build_for(project_id) + book = prices.book + transport_notes = list(prices.transport_notes) + transport_prices = {} + for variant in TRANSPORT_VARIANTS: + code = f"B-{TRANSPORT_CODE}#{variant['key']}" + if code in book.titles: + transport_prices[variant["key"]] = f"{book.resolve(code).total:,.0f}" + with_material = sum( + 1 + for unit_code, unit_title in book.titles.items() + if unit_title.kind is PriceKind.UNIT_PRICE and book.material_base(unit_code) > 0 + ) + + return JSONResponse( + content={ + "status": "success", + "ranges": ranges, + "machines": machines, + "misc_material": { + "percent": str(settings.get("misc_material_percent") or ""), + "min": str(MISC_MATERIAL_MIN_PERCENT), + "max": str(MISC_MATERIAL_MAX_PERCENT), + "basis": [ + "산림품셈 1-2-6 — 「각 항목에 명시되어 있지 않는 잡재료 및 소모재료 등을" + " 계상하고자 할 때에는 주재료비(재료비의 할증수량 제외)의 2~5%까지" + " 별도 계상하되 산정 근거를 명시하여야 한다」", + "⚠ 비워 두면 안 붙습니다 — 지금은 안 붙고 있는 상태입니다" + " (사용자 확정 2026-09-09 「지금은 안 넣되 숫자 넣으면 되게 열어 둘 것」).", + "⚠ 석공사·철골공사처럼 특수공구를 쓰는 자리는 건설품셈 1-2-6 이" + " 「별도 계상」으로 두었습니다 — 일반 비율을 그대로 붙이면 안 되는" + " 자리라, 그런 공종이 섞인 내역에서는 켜기 전에 살펴보십시오.", + ], + "base_items": with_material, + "base_note": ( + "" + if with_material + else "⚠ 지금은 일위대가에 주재료비가 선 공종이 하나도 없습니다" + " — 자재는 자재대 표에서 따로 금액이 섭니다. 값을 넣어도 붙을 밑수가" + " 없으므로, 사급 자재 단가가 서는 날 이 칸이 함께 살아납니다." + ), + }, + "transport": { + "distance_km": str(settings.get("transport_distance_km") or ""), + "road": str(settings.get("transport_road") or ""), + "roads": [ + {"key": row["key"], "label": row["label"]} for row in TRANSPORT_ROADS + ], + "variants": [ + { + "key": variant["key"], + "label": variant["label"], + "unit_price_krw": transport_prices.get(variant["key"], ""), + } + for variant in TRANSPORT_VARIANTS + ], + "basis": [TRANSPORT_BASIS, TRANSPORT_ASSUMPTION], + "notes": transport_notes, + }, + "labor_surcharge": { + "chosen": labor_surcharge_chosen, + "total_percent": f"{labor_surcharge_total:g}", + "reasons": labor_surcharge_reasons, + "series": [ + { + "key": item["key"], + "title": item["title"], + "section": item["section"], + "source_note": item["source_note"], + "options": [ + { + "key": option["key"], + "label": f"{option['label']} · {option['percent']:g}%", + } + for option in item["options"] + ], + } + for item in labor_surcharge_series + ], + "basis": [LABOR_SURCHARGE_SEAT, LABOR_SURCHARGE_COMBINE, LABOR_SURCHARGE_SCOPE], + }, + "notes": [ + "고를 수 있는 것은 원문에 적힌 값뿐입니다 — 그 밖의 수는 만들지 않습니다.", + "바꾸면 그 공종 단가가 바로 달라집니다. [저장]한 값은 이 프로젝트에만 걸립니다.", + ], + } + ) + except Exception: + logger.exception("B09 산출 조건 조회 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "산출 조건을 못 불러왔습니다."}, + ) + + +class FactorChoiceBody(BaseModel): + """고른 값 — 안 보낸 칸은 그대로 둔다.""" + + range_factor_choices: dict[str, str] | None = None + machine_choices: dict[str, str] | None = None + #: 공구손료·잡재료 비율 — **빈 문자열이면 안 붙는다**(칸을 도로 비우는 길). + misc_material_percent: str | None = None + #: 유가 지역(시도코드) — **빈 문자열이면 전국평균**으로 돌아간다. + fuel_region: str | None = None + #: 기계 수송 거리(㎞)·도로 구분 — **비면 수송비 줄이 안 선다.** + transport_distance_km: str | None = None + transport_road: str | None = None + #: 품 할인·할증(1-4) — 계열코드 → 고른 행. **빈 값이면 그 계열을 끄는 것.** + labor_surcharge: dict[str, str] | None = None + + +@router.put("/{project_id}/estimation/factors") +async def put_factor_choices(project_id: UUID, body: FactorChoiceBody) -> JSONResponse: + """산출 조건을 이 프로젝트에 저장한다. **다른 구획은 손대지 않는다.**""" + from B09_Estimation.B09_Estimation_FactorChoices import CHOICE_KEYS, MACHINE_OPTION_CODES + from common_util.common_util_project_settings import save_section + + from common_util.common_util_project_settings import estimation_settings + + root = await _project_root_of(project_id) + if root is None: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."}, + ) + values: dict[str, Any] = {} + if body.range_factor_choices is not None: + # ⚠ 모르는 값은 안 받는다 — 원문에 없는 수가 설정으로 들어오면 그것이 임의 수치다. + values["range_factor_choices"] = { + str(key): str(value) + for key, value in body.range_factor_choices.items() + if str(value) in CHOICE_KEYS + } + if body.machine_choices is not None: + values["machine_choices"] = { + str(key): str(value) + for key, value in body.machine_choices.items() + if str(value) in MACHINE_OPTION_CODES + } + if body.misc_material_percent is not None: + from B09_Estimation.B09_Estimation_UnitPrice import parse_misc_material_percent + + try: + percent = parse_misc_material_percent(body.misc_material_percent) + except ValueError as exc: + # ⚠ 조용히 깎아 넣지 않는다 — 범위 밖 값을 상한으로 접으면 사용자가 넣은 값과 + # 금액이 어긋난 채로 선다. + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + values["misc_material_percent"] = "" if percent is None else str(percent) + if body.fuel_region is not None: + from B09_Estimation.B09_Estimation_MachineOperating import load_regional_fuel_table + + region = str(body.fuel_region).strip() + table, _ = load_regional_fuel_table() + if region and region not in table: + # ⚠ 판에 없는 지역을 받아 두면 조용히 전국평균으로 서고 사용자는 지역값인 줄 안다. + return JSONResponse( + status_code=400, + content={ + "status": "error", + "message": f"유가 판에 없는 지역입니다: {region}", + }, + ) + values["fuel_region"] = region + if body.transport_distance_km is not None: + from B09_Estimation.B09_Estimation_Transport import parse_distance_km + + try: + distance = parse_distance_km(body.transport_distance_km) + except ValueError as exc: + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + values["transport_distance_km"] = "" if distance is None else str(distance) + if body.transport_road is not None: + from B09_Estimation.B09_Estimation_Transport import road_class + + road = str(body.transport_road).strip() + if road and road_class(road) is None: + # ⚠ 원문 표에 없는 도로 구분을 받아 두면 속도를 못 골라 조용히 안 선다. + return JSONResponse( + status_code=400, + content={"status": "error", "message": f"원문에 없는 도로 구분입니다: {road}"}, + ) + values["transport_road"] = road + if body.labor_surcharge is not None: + from B09_Estimation.B09_Estimation_LaborSurcharge import parse_choices + + # ⚠ **원문에 있는 선택지만** 받는다 — 없는 율이 설정으로 들어오면 그것이 임의 수치다. + stored = dict(estimation_settings(root).get("labor_surcharge") or {}) + for series_key, option_key in body.labor_surcharge.items(): + if str(option_key).strip(): + stored[str(series_key)] = str(option_key) + else: + stored.pop(str(series_key), None) # 빈 값 = 그 계열 끄기 + values["labor_surcharge"] = parse_choices(stored) + try: + save_section(root, "estimation", values, replace_keys=tuple(values)) + return JSONResponse(content={"status": "success", **values}) + except Exception: + logger.exception("B09 산출 조건 저장 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "산출 조건을 저장하지 못했습니다."}, + ) diff --git a/B09_Estimation/B09_Estimation_Router_MaterialPrices.py b/B09_Estimation/B09_Estimation_Router_MaterialPrices.py new file mode 100644 index 00000000..3becbfb0 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Router_MaterialPrices.py @@ -0,0 +1,111 @@ +"""B09 원가계산 라우터 — **자재 수동 단가** 읽기·저장 (PLAN 1장 Ⓐ · `…_MaterialPrices`). + +⚠ 저장은 단가·출처만 — 금액은 다음 조회 때 서버가 그 값으로 **다시 조립**함 + (브라우저 값을 받아 적지 않음). +""" + +from __future__ import annotations + +import logging +from datetime import date +from typing import Any +from uuid import UUID + +from fastapi import APIRouter +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +from B09_Estimation.B09_Estimation_MaterialPrices import ( + MATERIAL_PRICES_KEY, + MaterialPriceError, + listing, + merge, +) + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["B09 Estimation Material Prices"]) + + +class MaterialPriceChange(BaseModel): + """단가 칸 하나 — `price_krw` 가 비면 그 줄을 지움(단가 없음으로 돌아감).""" + + key: str + price_krw: Any = None + source: str = "" + name: str = "" + spec: str = "" + unit: str = "" + + +class MaterialPriceRequest(BaseModel): + changes: list[MaterialPriceChange] = Field(default_factory=list) + + +async def _rows(project_id: UUID) -> dict[str, Any]: + import json + + from B08_Quantity.B08_Quantity_Router_Material import get_handoff + from B09_Estimation.B09_Estimation_Router import _build_for, _project_root_of + from common_util.common_util_project_settings import estimation_settings + + root = await _project_root_of(project_id) + settings = estimation_settings(root) if root else {} + build = await _build_for(project_id) + # 자재총괄 줄(「이름 규격」 키) — 인계를 못 받으면 코드 줄만 서고 그 사실을 `handoff_note` 로. + handoff = json.loads(bytes((await get_handoff(project_id)).body).decode("utf-8")) + materials = handoff.get("materials") + rows = listing(build, settings.get(MATERIAL_PRICES_KEY), materials) + return { + "status": "success", + "rows": rows, + "handoff_note": "" + if materials is not None + else f"B08 인계를 못 받아 자재총괄 줄이 빠짐 — {handoff.get('message') or ''}", + "unconfirmed_count": sum(1 for row in rows if row.get("price_krw")), + } + + +@router.get("/{project_id}/estimation/material-prices") +async def get_material_prices(project_id: UUID) -> JSONResponse: + """단가 칸을 낼 자재 줄 — 자원 축 자재(코드) + 자원 축에서 사라진 저장 줄.""" + try: + return JSONResponse(content=await _rows(project_id)) + except Exception: + logger.exception("B09 자재 수동 단가 조회 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "자재 단가 목록을 못 만들었습니다."}, + ) + + +@router.put("/{project_id}/estimation/material-prices") +async def put_material_prices(project_id: UUID, payload: MaterialPriceRequest) -> JSONResponse: + """단가 저장 — 0 이하·수가 아닌 값은 받지 않음 · 값·출처가 같으면 넣은 날 그대로.""" + from B09_Estimation.B09_Estimation_Router import _project_root_of + from common_util.common_util_project_settings import estimation_settings, save_section + + root = await _project_root_of(project_id) + if root is None: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."}, + ) + try: + prices = merge( + estimation_settings(root).get(MATERIAL_PRICES_KEY), + [change.model_dump() for change in payload.changes], + date.today().isoformat(), + ) + except MaterialPriceError as error: + return JSONResponse(status_code=422, content={"status": "error", "message": str(error)}) + try: + save_section( + root, "estimation", {MATERIAL_PRICES_KEY: prices}, replace_keys=(MATERIAL_PRICES_KEY,) + ) + return JSONResponse(content=await _rows(project_id)) + except Exception: + logger.exception("B09 자재 수동 단가 저장 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "자재 단가를 저장하지 못했습니다."}, + ) diff --git a/B09_Estimation/B09_Estimation_Router_Progress.py b/B09_Estimation/B09_Estimation_Router_Progress.py new file mode 100644 index 00000000..ac5328f1 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Router_Progress.py @@ -0,0 +1,129 @@ +"""B09 기성 단계 탭 API — 계약내역 → 기성내역 · 기성 제잡비 계산서 (PLAN 12장 · 랩탑 메인). + +⚠ 계약내역(`contract_for`)과 계약 원가계산서(`cost_from_bill` 에 계약 직접비)를 **읽기만** 한다. + 저장은 `estimation.progress` 한 칸(회차 목록) — 설계·계약·원가계산서 저장본은 안 건드린다. +""" + +from __future__ import annotations + +import logging +from decimal import Decimal +from typing import Any +from uuid import UUID + +from fastapi import APIRouter +from fastapi.responses import JSONResponse + +from B09_Estimation.B09_Estimation_Progress import ( + CUT_CHOICES, + SETTINGS_KEY, + VAT_CHOICES, + clean_settings, + completion_sheet, + progress_sheet, +) +from B09_Estimation.B09_Estimation_Rates import RateLookupError + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["B09 Estimation — Progress"]) + + +def _cut_options(first: str) -> list[dict[str, str]]: + """절사 9택 — 첫 칸은 화면 표기 그대로(빈 값).""" + return [ + {"key": unit, "label": f"{int(unit):,}원 미만" if unit else first} for unit in CUT_CHOICES + ] + + +async def progress_for( + project_id: UUID, round: int | None +) -> tuple[dict[str, Any], dict[str, Any]] | JSONResponse: + """(기성 저장값, 기성 한 장) — 못 서면 그 까닭 응답. 준공도 이 길로 기성본을 받음.""" + from B09_Estimation.B09_Estimation_Router_Contract import contract_for + from B09_Estimation.B09_Estimation_Router_CostSheet import cost_from_bill + from common_util.common_util_project_settings import estimation_settings + + found = await contract_for(project_id) + if isinstance(found, JSONResponse): + return found + root, bill, _, contract = found + totals = contract["totals"]["contract"] + direct = {part: Decimal(totals[f"{part}_krw"]) for part in ("material", "labor", "expense")} + try: + cost_data, cost_result, _ = cost_from_bill(root, bill, direct) + except RateLookupError as error: + return JSONResponse(status_code=422, content={"status": "error", "message": str(error)}) + stored, _ = clean_settings(dict(estimation_settings(root).get(SETTINGS_KEY) or {})) + return stored, progress_sheet(contract["rows"], cost_data, cost_result, stored, round) + + +@router.get("/{project_id}/estimation/completion") +async def get_completion(project_id: UUID) -> JSONResponse: + """준공 한 장 — 계약금액 | 준공금액(기성 마지막 회차 누계를 옮김).""" + found = await progress_for(project_id, None) + if isinstance(found, JSONResponse): + return found + return JSONResponse( + content={ + "status": "success", + **completion_sheet(found[1]), + "limit_note": ( + "준공 별도 계산 규칙 미확인(35번 §3) — 기성 마지막 회차 누계를 옮기기만 함 · " + "준공 표본 0건" + ), + } + ) + + +@router.get("/{project_id}/estimation/progress") +async def get_progress(project_id: UUID, round: int | None = None) -> JSONResponse: + """기성 한 장 — `round` 회차(1부터)를 금회로 · 없으면 마지막 회차.""" + found = await progress_for(project_id, round) + if isinstance(found, JSONResponse): + return found + stored, sheet = found + return JSONResponse( + content={ + "status": "success", + **sheet, + "settings": stored, + "fields": { + "vat": [{"key": k, "label": label} for k, label in VAT_CHOICES], + "supply_cut": _cut_options("총공사비에서 조정"), + "total_cut": _cut_options("이윤금액 직접입력"), + }, + "limit_note": ( + "기성 표본 0건 — 구조가 서는지까지만 확인됨 · 계약잡비율 역산(도급액 ÷ 계약 " + "직접공사비)·회차별 절사는 구조로 읽음 · 사정은 뜻 미확인이라 칸만" + ), + } + ) + + +@router.put("/{project_id}/estimation/progress") +async def put_progress(project_id: UUID, body: dict[str, Any]) -> JSONResponse: + """회차 목록 저장 — 틀린 칸이 있으면 아무것도 안 저장.""" + from B09_Estimation.B09_Estimation_Router import _project_root_of + from common_util.common_util_project_settings import save_section + + root = await _project_root_of(project_id) + if root is None: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."}, + ) + cleaned, errors = clean_settings(body) + if errors: + return JSONResponse( + status_code=400, + content={"status": "error", "message": " · ".join(errors), "errors": errors}, + ) + try: + save_section(root, "estimation", {SETTINGS_KEY: cleaned}, replace_keys=(SETTINGS_KEY,)) + except Exception: + logger.exception("B09 기성 저장 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "기성 회차를 저장하지 못했습니다."}, + ) + return JSONResponse(content={"status": "success", "settings": cleaned}) diff --git a/B09_Estimation/B09_Estimation_Statutory.py b/B09_Estimation/B09_Estimation_Statutory.py index 2fd37e8b..2351b0b3 100644 --- a/B09_Estimation/B09_Estimation_Statutory.py +++ b/B09_Estimation/B09_Estimation_Statutory.py @@ -151,6 +151,35 @@ def _guarantee_percent_from_formula(row: dict[str, Any], *, label: str) -> Decim return Decimal(match.group(1)) +#: 70억 이상 구간 식 — `(기초액 + (direct_cost - 기준액) * 율%) * duration_years`. +_RE_GUARANTEE_STEP = re.compile(r"\(\s*(\d+)\s*\+\s*\(\s*direct_cost\s*-\s*(\d+)\s*\)") + + +def _guarantee_step(row: dict[str, Any]) -> tuple[Decimal, Decimal]: + """(기초액, 기준액) — 70억 미만 식은 둘 다 0. + + ⚠ 2026-09-14 고침: 종전엔 백분율만 뽑아 **기초액·기준액 차감을 버렸다**(직공비 70억 이상에서 + 틀림 — 조달청 제비율 적용기준 §10 「[79만원 + (직공비−75억원) × 0.0070%] × 공기(년)」). + """ + match = _RE_GUARANTEE_STEP.search(str(row.get("formula", ""))) + if not match: + return _ZERO, _ZERO + return Decimal(match.group(1)), Decimal(match.group(2)) + + +#: 15. 이행보증 — 일반계약이면 **추정가격 300억 이상**일 때만 계상(STmate 힌트 「일반계약일경우 +#: 추정가격 300억 이상공사에 적용」 · 지방계약법 시행령 이행보증서 의무 대상 · 제비율 기준 +#: 「소규모 공사는 통상 비대상」). 최저가·기술제안이면 규모와 무관하게 계상. +PERFORMANCE_GUARANTEE_GENERAL_MIN_KRW = Decimal(30_000_000_000) + + +def _performance_guarantee_applies(data: CostInput, ctx: ExpenseContext) -> bool: + mode = str(getattr(data, "performance_guarantee_mode", "general") or "general") + if mode == "lowest_price_tech": + return True + return ctx.scale_reference >= PERFORMANCE_GUARANTEE_GENERAL_MIN_KRW + + def safety_management_cost( dataset: RateDataset, data: CostInput, @@ -177,16 +206,46 @@ def safety_management_cost( base_with = ctx.material_cost + ctx.direct_labor_cost + owner_supplied base_without = ctx.material_cost + ctx.direct_labor_cost + # ⚠ 2026-09-14 고침 — 대상액 50억 이상에서 두 줄(선임 대상 아님/대상)이 겹쳐 멈추던 자리. + # 고시 별표1 은 「대상액 50억원 이상」 열과 **따로** 「영 별표5에 따른 보건관리자 선임 대상 + # 건설공사의 적용비율」 열을 둔다 — 대상액이 아니라 **공사가 선임 대상인가**로 가르는 열. + # 선임 대상 금액은 요율 데이터 `manager_thresholds`(800억 · 토목 1,000억, 산업안전보건법 + # 시행령 별표5 — 원문 미보유). 추정금액 = 규모 기준액으로 견줌. + thresholds = variable.get("manager_thresholds") or {} + limit = thresholds.get( + "civil_main_work_estimated_amount_krw" + if data.work_type_safety == "civil" + else "default_estimated_amount_krw" + ) + manager_target = limit is not None and ctx.scale_reference >= Decimal(str(limit)) + def evaluate( base: Decimal, multiplier: Decimal, label: str ) -> tuple[Decimal, Decimal, Decimal]: - row = select_bracket( - brackets, - amount_field="target_amount_bracket", - amount=base, - equals={"work_type": data.work_type_safety}, - label=label, - ) + if manager_target: + row = next( + ( + r + for r in brackets + if str(r["target_amount_bracket"]).endswith("at_or_above_manager_threshold") + and r.get("work_type") == data.work_type_safety + ), + None, + ) + if row is None: + raise RateLookupError(f"{label}: 보건관리자 선임 대상 요율이 없습니다") + else: + row = select_bracket( + [ + r + for r in brackets + if not str(r["target_amount_bracket"]).endswith("at_or_above_manager_threshold") + ], + amount_field="target_amount_bracket", + amount=base, + equals={"work_type": data.work_type_safety}, + label=label, + ) percent = rate_percent(row, label=label) flat = base_amount(row) return (base * percent / _HUNDRED + flat) * multiplier, percent, flat @@ -278,16 +337,36 @@ def statutory_expenses( ) continue + if key == "performance_guarantee_fee" and not _performance_guarantee_applies(data, ctx): + continue # 15. 일반계약·추정가격 300억 미만 — 줄 자체를 만들지 않는다 + if key == "subcontract_payment_guarantee" and ( + str(getattr(data, "subcontract_guarantee", "off") or "off") != "on" + ): + # 기본 꺼짐(2026-09-14) — 실무 원가계산서 여섯 건에 이 줄이 없음. 건설산업기본법 + # 제34조 대상(하도급대금 지급보증서 교부)이면 설계자가 켬. + continue + base, percent, note = _base_and_rate(dataset, data, ctx, item) if base is None: continue # 적용 하한 미달 — 줄 자체를 만들지 않는다 + flat = _ZERO + if key == "performance_guarantee_fee": + # 70억 이상 구간 기초액 × 공기(년) — 밑수는 `_base_and_rate` 가 기준액을 뺐다. + row = select_bracket( + dataset.variable(item.rate_variable)["brackets"], + amount_field="direct_cost_bracket", + amount=ctx.direct_construction_cost, + label=item.name, + ) + flat = _guarantee_step(row)[0] * Decimal(str(data.duration_days)) / Decimal(365) amount = emit( key=key, name=item.name, base_label=item.base_label, base=base, percent=percent, + flat=flat, note=note, ) if key == "health_insurance": @@ -311,6 +390,15 @@ def _base_and_rate( return ctx.total_labor_cost, Decimal(str(variable["rate_percent"])), "" if key == "employment_insurance": + # 4. 등급 — 「없음」이면 안 섬 · 등급을 고르면 그 줄 · 자동이면 추정금액 구간(종전). + grade = str(getattr(data, "employment_insurance_grade", "auto") or "auto") + if grade == "none": + return None, _ZERO, "" + if grade != "auto": + row = next((r for r in variable["brackets"] if str(r.get("grade")) == grade), None) + if row is None: + raise RateLookupError(f"고용보험료: 요율표에 없는 등급입니다 — {grade}") + return ctx.total_labor_cost, rate_percent(row, label=item.name), f"{grade}등급 선택" # 등급이 추정가격으로 갈린다. 임도는 대개 고시 기준금액 미만이라 숫자 구간에 안 걸리므로 # 잔여 구간을 이름으로 지정한다(등급 7·그 이하 모두 같은 요율). row = select_bracket( @@ -362,11 +450,16 @@ def _base_and_rate( return ctx.direct_construction_cost, rate_percent(row, label=item.name), note if key == "retirement_mutual_aid": - if not _threshold_met( + # 5. 적용 여부 — 자동은 추정금액 하한(종전) · 적용/미적용은 설계자가 고름. + mode = str(getattr(data, "retirement_mutual_aid_mode", "auto") or "auto") + if mode == "none": + return None, _ZERO, "" + if mode == "auto" and not _threshold_met( dataset, item.rate_variable, "minimum_estimated_amount_krw", ctx.scale_reference ): return None, _ZERO, "" - return ctx.direct_labor_cost, Decimal(str(variable["rate_percent"])), "" + note = "적용 선택(추정금액 하한과 무관)" if mode == "apply" else "" + return ctx.direct_labor_cost, Decimal(str(variable["rate_percent"])), note if key == "equipment_payment_guarantee": rows = variable["general_construction"] + variable["specialty_construction"] @@ -381,6 +474,28 @@ def _base_and_rate( return ctx.direct_construction_cost, rate_percent(row, label=item.name), "" if key == "subcontract_payment_guarantee": + # 14. 낙찰방식 — 턴키(대안)는 규모와 무관한 한 줄(0.084) · 종합심사 대상은 300억 이상 줄. + bid = str(getattr(data, "bid_method", "not_comprehensive") or "not_comprehensive") + if bid == "turnkey": + row = next( + ( + r + for r in variable["brackets"] + if r["estimated_price_bracket"].startswith("turnkey") + ), + None, + ) + if row is None: + raise RateLookupError(f"{item.name}: 턴키(대안) 요율이 요율 데이터에 없습니다") + return ctx.direct_construction_cost, rate_percent(row, label=item.name), "턴키(대안)" + if bid == "comprehensive": + suffix = f"gte_30_billion_{data.subcontract_guarantee_variant}" + row = next( + (r for r in variable["brackets"] if r["estimated_price_bracket"] == suffix), None + ) + if row is None: + raise RateLookupError(f"{item.name}: 종합심사 대상 요율이 없습니다 — {suffix}") + return ctx.direct_construction_cost, rate_percent(row, label=item.name), "종합심사 대상" # 30억 이상 구간은 공종(토목·산업설비 / 건축)으로 한 번 더 갈린다. row = select_bracket( variable["brackets"], @@ -401,6 +516,11 @@ def _base_and_rate( percent = _guarantee_percent_from_formula(row, label=item.name) years = Decimal(str(data.duration_days)) / Decimal(365) note = variable.get("typical_forest_road_applicability", "") - return ctx.direct_construction_cost * years, percent, f"임도 적용성: {note}" if note else "" + deduct = _guarantee_step(row)[1] + return ( + (ctx.direct_construction_cost - deduct) * years, + percent, + f"임도 적용성: {note}" if note else "", + ) raise RateLookupError(f"밑수 정의가 없는 비목입니다: {key}") diff --git a/B09_Estimation/B09_Estimation_Storage.py b/B09_Estimation/B09_Estimation_Storage.py index 3f75a739..8743737e 100644 --- a/B09_Estimation/B09_Estimation_Storage.py +++ b/B09_Estimation/B09_Estimation_Storage.py @@ -128,6 +128,7 @@ def _detail_to_dict(detail: PriceDetail) -> dict[str, Any]: "percent_of_material": ( None if detail.percent_of_material is None else str(detail.percent_of_material) ), + "output": None if detail.output is None else str(detail.output), } @@ -144,6 +145,7 @@ def _detail_from_dict(raw: dict[str, Any]) -> PriceDetail: percent_of_labor=None if labor_percent is None else Decimal(str(labor_percent)), percent_of_labor_target=str(raw.get("percent_of_labor_target") or "expense"), percent_of_material=(None if material_percent is None else Decimal(str(material_percent))), + output=None if raw.get("output") is None else Decimal(str(raw["output"])), ) diff --git a/B09_Estimation/B09_Estimation_UI_BaseData.ts b/B09_Estimation/B09_Estimation_UI_BaseData.ts deleted file mode 100644 index c23cac17..00000000 --- a/B09_Estimation/B09_Estimation_UI_BaseData.ts +++ /dev/null @@ -1,1161 +0,0 @@ -/* ============================================================================= - * B09_Estimation_UI_BaseData.ts - * 기초자료 탭 · 중기 탭 — 목록표 넷을 그린다 (사용자 확정 12번 「내야 할 표 16개 전체」). - * - * 기초자료 탭 : 노무비목록표 · 재료비목록표 · 경비목록표 - * 중기 탭 : 중기목록표 (합계 + 노무·재료·경비 3분할) - * - * 서식은 지어내지 않았다 — 실무 내역서(영월 기번6 · 봉화 기번41)의 같은 이름 시트를 - * 그대로 옮겼다. 칸 이름·차례가 그 시트와 같다. - * - * 화면 조립부(`B09_Estimation_UI_Page.ts`)가 이미 700줄을 크게 넘어 여기로 뺐다. - * 표를 그리는 일만 하고 **상태를 들지 않는다** — 부르는 쪽이 자료를 넘긴다. - * ========================================================================== */ - -import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; -import { - attachProvenance, - markProvenanceCell, - type ProvenancePayload, - type ProvenanceSheet, -} from "@ui/ui_template_provenance"; -import { API_BASE_URL } from "@config/config_frontend"; - -function L(key: keyof typeof ui_locales): string { - return ui_locales[key][currentLanguageIndex]; -} - -/** 목록표 한 줄 — 실무 시트 칸 그대로. */ -export interface BaseDataRow { - code: string; - name: string; - spec: string; - unit: string; - unit_price_krw: string | null; - note: string; -} - -/** 중기목록표 한 줄 — 합계와 3분할을 함께 보인다. */ -export interface MachineRow { - code: string; - name: string; - spec: string; - unit: string; - total_krw: string | null; - labor_krw: string | null; - material_krw: string | null; - expense_krw: string | null; - note: string; -} - -export interface BaseDataDto { - status: string; - labor: BaseDataRow[]; - material: BaseDataRow[]; - expense: BaseDataRow[]; - machine: MachineRow[]; - /** 근거 사전 — **개발환경에서만** 온다. 없으면 호버·등급색이 통째로 안 붙는다. */ - provenance?: ProvenancePayload; -} - -export async function fetchBaseData(projectId: string): Promise { - const response = await fetch( - `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/base-data`, - { credentials: "include" }, - ); - if (!response.ok) throw new Error(`base-data ${response.status}`); - return (await response.json()) as BaseDataDto; -} - -function money(value: string | null): string { - if (value === null || value === "") return ""; - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed.toLocaleString("ko-KR") : value; -} - -function head(text: string): HTMLElement { - const el = document.createElement("div"); - el.className = "b09-hint"; - el.style.fontWeight = "600"; - el.textContent = text; - return el; -} - -function note(text: string): HTMLElement { - const el = document.createElement("div"); - el.className = "b09-hint"; - el.textContent = text; - return el; -} - -/** - * 표 한 장. - * - * `keys`·`sheet` 를 함께 주면 칸마다 근거 호버가 붙는다 — **사전이 없으면 아무 일도 - * 안 한다**(빈 카드를 띄우면 「설명이 있다」는 거짓만 남는다). 안 주는 표는 종전 그대로다. - */ -function table( - headers: string[], - rows: string[][], - leftCols: number[], - keys?: string[], - sheet?: ProvenanceSheet, -): HTMLElement { - const el = document.createElement("table"); - el.className = "b09-sheet"; - const thead = document.createElement("thead"); - const headRow = document.createElement("tr"); - headers.forEach((text, index) => { - const th = document.createElement("th"); - th.textContent = text; - if (leftCols.includes(index)) th.className = "b09-left"; - headRow.append(th); - }); - thead.append(headRow); - const tbody = document.createElement("tbody"); - for (const cells of rows) { - const tr = document.createElement("tr"); - cells.forEach((text, index) => { - const td = document.createElement("td"); - td.textContent = text; - if (leftCols.includes(index)) td.className = "b09-left"; - const key = keys?.[index]; - const column = key ? sheet?.columns[key] : undefined; - if (key && column) markProvenanceCell(td, key, column.tier); - tr.append(td); - }); - tbody.append(tr); - } - el.append(thead, tbody); - attachProvenance(el, sheet); - return el; -} - -/** 목록표 한 장 — 코드·명칭·규격·단위·단가·비고 (실무 시트와 같은 칸). */ -function catalogTable(rows: BaseDataRow[], sheet?: ProvenanceSheet): HTMLElement { - return table( - ["코드번호", "명 칭", "규 격", "단위", "단 가", "비 고"], - rows.map((row) => [ - row.code, - row.name, - row.spec, - row.unit, - money(row.unit_price_krw), - row.note, - ]), - [0, 1, 2, 5], - ["code", "name", "spec", "unit", "unit_price_krw", "note"], - sheet, - ); -} - -/** - * 기초자료 탭 — 목록표 셋. - * - * ⚠ 표가 비거나 한 줄뿐일 때 **그냥 두지 않는다** — 「다 채운 것」으로 읽히기 때문이다. - * 재료비목록표가 지금 그 자리다(사급 자재 카탈로그가 아직 안 섰다). - */ -export function drawBaseDataTab(body: HTMLElement, data: BaseDataDto): void { - const groups: Array<[string, BaseDataRow[], string]> = [ - ["노무비목록표", data.labor, ""], - [ - "재료비목록표", - data.material, - data.material.length <= 1 - ? "⚠ 사급 자재 카탈로그가 아직 서지 않아 줄이 거의 없습니다 — 자재값 출처(업체 견적·물가지)를 붙이면 채워집니다." - : "", - ], - ["경비목록표", data.expense, "기계 취득가격입니다(천원) — 시간당 사용료는 「중기」 탭입니다."], - ]; - for (const [title, rows, hint] of groups) { - body.append(head(`${title} (${rows.length})`)); - if (hint) body.append(note(hint)); - if (rows.length === 0) { - body.append(note("아직 선 줄이 없습니다 — 값을 0 으로 채우지 않습니다.")); - continue; - } - body.append(catalogTable(rows, data.provenance?.sheets?.catalog)); - } -} - -/** 중기 탭 — 중기목록표. 합계와 3분할을 함께 보인다(실무 시트와 같은 칸). */ -export function drawMachineTab(body: HTMLElement, data: BaseDataDto): void { - body.append(head(`중기목록표 (${data.machine.length})`)); - if (data.machine.length === 0) { - body.append(note("아직 선 줄이 없습니다 — 값을 0 으로 채우지 않습니다.")); - return; - } - body.append( - table( - ["코드번호", "명 칭", "규 격", "단위", "합 계", "노 무 비", "재 료 비", "경 비", "비 고"], - data.machine.map((row) => [ - row.code, - row.name, - row.spec, - row.unit, - money(row.total_krw), - money(row.labor_krw), - money(row.material_krw), - money(row.expense_krw), - row.note, - ]), - [0, 1, 2, 8], - [ - "code", - "name", - "spec", - "unit", - "total_krw", - "labor_krw", - "material_krw", - "expense_krw", - "note", - ], - data.provenance?.sheets?.machine, - ), - ); - // ⚠ 계산 과정을 감추지 않는다(PLAN 8-13). 조종원 환산이 실무와 다른 것을 여기서 밝힌다. - body.append( - note( - "조종원 노임은 「노임 ÷ 8시간 × 16/12 × 25/20」(약 1.667배)으로 셉니다. " + - "공표 노임은 기본급여액뿐이라 제수당·상여금·퇴직급여충당금을 따로 계상해야 " + - "합니다(건협 임금적용요령 4-나 · 기재부 정부 입찰·계약 집행기준 제76조의3). " + - "⚠ 계수 자체의 예규 원문은 아직 못 봐 실무 관행을 따랐습니다 — 실무 두 공사지· " + - "임도교본 예제·상용 적산 프로그램이 모두 같은 계수를 씁니다.", - ), - ); - body.append(note("잡재료(주연료의 %)는 연료 소요량에 포함되어 있습니다 — 따로 세지 않습니다.")); -} - -/* ============================================================================= - * 설계서 구성표 — 법이 정한 목차와 우리가 내는 것을 맞대 본다(별표2 (5)(가)). - * ⚠ 「없음」과 「우리 몫 아님」을 갈라 보인다 — 갈라야 다음에 할 일이 달라진다. - * ========================================================================== */ - -export interface DesignDocDto { - status: string; - law: string; - summary: string; - counts: Record; - items: Array<{ - order: number; - name: string; - status: string; - owner: string; - where: string; - note: string; - }>; - notes: string[]; -} - -export async function fetchDesignDocIndex(projectId: string): Promise { - const response = await fetch( - `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/design-doc-index`, - { credentials: "include" }, - ); - if (!response.ok) throw new Error(`design-doc-index ${response.status}`); - return (await response.json()) as DesignDocDto; -} - -export function drawDesignDocTab(body: HTMLElement, data: DesignDocDto): void { - body.append(head(`설계서 구성 (법이 정한 ${data.items.length})`)); - body.append(note(data.summary)); - body.append(note(data.law)); - body.append( - table( - ["차례", "이 름", "상 태", "누가 만드나", "어디서 나오나", "비 고"], - data.items.map((row) => [ - String(row.order), - row.name, - row.status, - row.owner, - row.where || "—", - row.note, - ]), - [0, 1, 2, 3, 4, 5], - ), - ); - for (const line of data.notes) body.append(note(line)); -} - -/* ============================================================================= - * 각종 중기경비계산서 — 기종마다 한 장(별표2 (5)(가) 아홉째). - * ⚠ 목록표가 「얼마」라면 이 장은 **왜 그 값인가**다. 계산 과정을 감추지 않는다. - * ========================================================================== */ - -export interface MachineExpenseDto { - status: string; - summary: string; - notes: string[]; - sheets: Array<{ - machine_code: string; - name: string; - spec: string; - price_thousand_krw: string | null; - economic_life_hours: number | null; - annual_standard_hours: number | null; - depreciation_coefficient: number | null; - maintenance_coefficient: number | null; - management_coefficient: number | null; - loss_coefficient: number | null; - loss_krw_per_hour: string | null; - fuel_liters_per_hour: string | null; - fuel_price_per_liter: string | null; - fuel_scope: string; - misc_material_percent: string | null; - operator_code: string; - operator_daily_wage: string | null; - operator_krw_per_hour: string | null; - material_krw: string | null; - labor_krw: string | null; - expense_krw: string | null; - total_krw: string | null; - variant: string; - attachment: boolean; - attachment_note: string; - gaps: string[]; - }>; -} - -export async function fetchMachineExpense(projectId: string): Promise { - const response = await fetch( - `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/machine-expense`, - { credentials: "include" }, - ); - if (!response.ok) throw new Error(`machine-expense ${response.status}`); - return (await response.json()) as MachineExpenseDto; -} - -/** 기종 한 장 — 손료·운전경비·시간당 사용료를 차례로. */ -function machineExpenseSheet(sheet: MachineExpenseDto["sheets"][number]): HTMLElement { - const box = document.createElement("div"); - box.className = "b09-panel__group"; - const title = document.createElement("p"); - title.className = "b09-panel__legend"; - title.textContent = - `${sheet.machine_code} ${sheet.name} ${sheet.spec}`.trim() + - (sheet.variant ? ` — ${sheet.variant}` : ""); - box.append(title); - - const coefficient = (value: number | null) => (value === null ? "—" : String(value)); - box.append( - table( - ["구 분", "내 용", "값"], - [ - ["① 손료", "취득가격(천원)", money(sheet.price_thousand_krw)], - [ - "", - "내용시간 / 연간표준가동시간", - `${coefficient(sheet.economic_life_hours)} / ${coefficient(sheet.annual_standard_hours)}`, - ], - [ - "", - "상각비·정비비·관리비 계수 (10⁻⁷)", - `${coefficient(sheet.depreciation_coefficient)} + ${coefficient(sheet.maintenance_coefficient)} + ${coefficient(sheet.management_coefficient)} = ${coefficient(sheet.loss_coefficient)}`, - ], - ["", "시간당 손료(원)", money(sheet.loss_krw_per_hour)], - [ - "② 운전경비", - `주연료(L/hr) × 유가(${sheet.fuel_scope})`, - `${sheet.fuel_liters_per_hour ?? "—"} × ${money(sheet.fuel_price_per_liter)}`, - ], - ["", "잡재료(주연료의 %)", sheet.misc_material_percent ?? "—"], - [ - "", - `조종원(${sheet.operator_code || "—"}) 일당 → 시간당`, - `${money(sheet.operator_daily_wage)} → ${money(sheet.operator_krw_per_hour)}`, - ], - [ - "③ 시간당 사용료", - "재료비 / 노무비 / 경비", - `${money(sheet.material_krw)} / ${money(sheet.labor_krw)} / ${money(sheet.expense_krw)}`, - ], - ["", "합 계", money(sheet.total_krw)], - ], - [0, 1], - ), - ); - if (sheet.variant) { - box.append( - note( - "같은 기종이라도 조합 사용이면 잡재료가 16% 로 줄어 재료비가 달라집니다 —" + - " 그래서 층이 따로 섭니다(건설품셈 제8장 [주]⑤).", - ), - ); - } - if (sheet.attachment_note) box.append(note(sheet.attachment_note)); - for (const gap of sheet.gaps) box.append(note(`⚠ ${gap}`)); - return box; -} - -export function drawMachineExpense(body: HTMLElement, data: MachineExpenseDto): void { - body.append(head(`각종 중기경비계산서 (${data.sheets.length})`)); - body.append(note(data.summary)); - for (const line of data.notes) body.append(note(line)); - for (const sheet of data.sheets) body.append(machineExpenseSheet(sheet)); -} - -/* ============================================================================= - * 산출기초 — 줄에 달린 근거를 한 장으로 모은 장(별표2 (5)(가) 열셋째). - * ⚠ 여기서 값을 다시 계산하지 않는다 — 모으기만 한다. - * ========================================================================== */ - -export interface BasisSheetDto { - status: string; - provenance?: ProvenancePayload; - note: string; - summary: string; - dataset_versions: Array<{ - dataset_id: string; - file: string; - effective_date: string; - sha256: string; - }>; - chosen_conditions: Array<{ item: string; value: string }>; - work_items: Array<{ code: string; name: string; unit: string; notes: string[] }>; - gaps: Array<{ kind: string; code: string; reason: string }>; -} - -export async function fetchBasisSheet(projectId: string): Promise { - const response = await fetch( - `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/basis-sheet`, - { credentials: "include" }, - ); - if (!response.ok) throw new Error(`basis-sheet ${response.status}`); - return (await response.json()) as BasisSheetDto; -} - -export function drawBasisSheet(body: HTMLElement, data: BasisSheetDto): void { - body.append(head("산출기초")); - body.append(note(data.summary)); - body.append(note(data.note)); - - body.append(head(`① 어느 판으로 계산했나 (${data.dataset_versions.length})`)); - body.append( - table( - ["자료", "파일", "기준일", "지문(앞 12)"], - data.dataset_versions.map((row) => [ - row.dataset_id, - row.file, - row.effective_date, - row.sha256 || "—", - ]), - [0, 1, 2, 3], - ["dataset_id", "file", "effective_date", "sha256"], - data.provenance?.sheets?.basis_versions, - ), - ); - - body.append(head(`② 무엇을 골랐나 (${data.chosen_conditions.length})`)); - if (data.chosen_conditions.length === 0) { - body.append(note("고른 값이 없습니다 — 전부 확정 기본값으로 돌고 있습니다.")); - } else { - body.append( - table( - ["항 목", "고른 값"], - data.chosen_conditions.map((row) => [row.item, row.value]), - [0, 1], - ["item", "value"], - data.provenance?.sheets?.basis_chosen, - ), - ); - } - - body.append(head(`③ 공종마다 무엇을 근거로 했나 (${data.work_items.length})`)); - body.append( - table( - ["코드", "공 종", "단위", "근 거"], - data.work_items.map((row) => [row.code, row.name, row.unit, row.notes.join(" · ")]), - [0, 1, 2, 3], - ["code", "name", "unit", "notes"], - data.provenance?.sheets?.basis_items, - ), - ); - - body.append(head(`④ 못 채운 자리 (${data.gaps.length})`)); - if (data.gaps.length === 0) { - body.append(note("못 채운 자리가 없습니다.")); - } else { - body.append( - table( - ["갈 래", "코드", "사 유"], - data.gaps.map((row) => [row.kind, row.code, row.reason]), - [0, 1, 2], - ["kind", "code", "reason"], - data.provenance?.sheets?.basis_gaps, - ), - ); - body.append(note("⚠ 여기 있는 것은 0 으로 때우지 않고 남겨 둔 자리입니다.")); - } -} - -/** 두 탭이 함께 쓰는 「아직 못 불러왔습니다」 문구. */ -export function drawBaseDataError(body: HTMLElement): void { - body.append(note(L("B09_Estimation_Tab_Pending"))); -} - -/* ============================================================================= - * 자재단가대비표(A9) · 환율및기초자료(A10) — 사용자 확정 ③·⑮ 와 한 벌. - * - * 표를 냈는데 화면에 없으면 **낸 것이 아니다.** 그래서 기초자료 탭 아래에 붙인다. - * 서식은 실무 「자재단가대비표」·「환율및기초자료」 시트를 그대로 옮겼다 — - * 원천마다 **단가·페이지** 두 칸이 서고, 채택한 원천에 표시가 붙는다. - * ========================================================================== */ - -/** 원천 한 칸 — 값이 없으면 **빈칸**이다. 0 을 넣으면 「0원짜리 견적」으로 읽힌다. */ -export interface PriceSlot { - name: string; - price_krw: string | null; - /** 「페이지」 자리 — 물가지는 쪽수, 견적은 업체명·날짜(확정 ③). */ - source_note: string; - adopted: boolean; -} - -export interface MaterialComparisonRow { - code: string; - name: string; - spec: string; - unit: string; - slots: PriceSlot[]; - adopted_slot: number; - adopted_price_krw: string | null; - note: string; -} - -export interface FuelScope { - key: string; - label: string; - available: boolean; - why?: string; -} - -export interface PriceSourcesDto { - status: string; - provenance?: ProvenancePayload; - material_comparison: { - slot_names: string[]; - rows: MaterialComparisonRow[]; - notes: string[]; - }; - base_reference: { - exchange: { rows: unknown[]; note: string }; - labor: { - rows: Array<{ - code: string; - name: string; - day_wage_krw: string | null; - hourly_krw: string | null; - formula: string; - }>; - note: string; - }; - fuel: { - diesel_krw_per_l: string | null; - scope: string; - effective_date: string; - dataset_id: string; - scopes: FuelScope[]; - /** 고를 수 있는 시도 — 판에 있는 것만. 값을 함께 실어 고르기 전에 견줄 수 있다. */ - regions: Array<{ code: string; name: string; diesel_krw_per_l: string | null }>; - region: string; - region_name: string; - region_missing?: string; - note: string; - }; - }; -} - -export async function fetchPriceSources(projectId: string): Promise { - const response = await fetch( - `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/price-sources`, - { credentials: "include" }, - ); - if (!response.ok) throw new Error(`price-sources ${response.status}`); - return (await response.json()) as PriceSourcesDto; -} - -/** - * 자재단가대비표 — 원천마다 **단가·페이지** 두 칸이라 머리글이 두 줄이다. - * 채택한 원천 칸에 표시를 넣어 「어느 것을 썼나」가 한눈에 보이게 한다. - */ -function comparisonTable( - slotNames: string[], - rows: MaterialComparisonRow[], - sheet?: ProvenanceSheet, -): HTMLElement { - const el = document.createElement("table"); - el.className = "b09-sheet"; - - const thead = document.createElement("thead"); - const top = document.createElement("tr"); - const bottom = document.createElement("tr"); - // ⚠ 슬롯 6 이 곧 「적용 단가」다(`JUKNM=6`). 그 자리에 「적 용」 칸을 또 세우면 - // 같은 값이 두 번 선다 — 실무 시트도 원천 다섯 + 적용 하나로 끝난다. - const appliedIsLastSlot = - rows.length > 0 && rows.every((row) => row.adopted_slot === slotNames.length); - const trailing = appliedIsLastSlot ? [] : ["적 용"]; - const fixed = ["코드번호", "명 칭", "규 격", "단위"]; - fixed.forEach((text, index) => { - const th = document.createElement("th"); - th.textContent = text; - th.rowSpan = 2; - if (index <= 2) th.className = "b09-left"; - top.append(th); - }); - for (const name of [...slotNames, ...trailing]) { - const th = document.createElement("th"); - th.textContent = name; - th.colSpan = 2; - top.append(th); - for (const sub of ["단 가", "페이지"]) { - const cell = document.createElement("th"); - cell.textContent = sub; - bottom.append(cell); - } - } - const noteHead = document.createElement("th"); - noteHead.textContent = "비 고"; - noteHead.rowSpan = 2; - noteHead.className = "b09-left"; - top.append(noteHead); - thead.append(top, bottom); - - const tbody = document.createElement("tbody"); - for (const row of rows) { - const tr = document.createElement("tr"); - /** 칸 하나 — `key` 를 주면 근거 호버가 붙는다(사전에 없는 열은 아무 일도 안 한다). */ - const put2 = (td: HTMLElement, key: string, tier?: string): void => { - const column = sheet?.columns[key]; - if (column) markProvenanceCell(td, key, tier ?? column.tier); - }; - const put = (text: string, left = false, key?: string): void => { - const td = document.createElement("td"); - td.textContent = text; - if (left) td.className = "b09-left"; - if (key) put2(td, key); - tr.append(td); - }; - put(row.code, true, "code"); - put(row.name, true, "name"); - put(row.spec, true, "spec"); - put(row.unit, false, "unit"); - for (const slot of row.slots) { - const td = document.createElement("td"); - td.textContent = money(slot.price_krw); - // 채택한 원천을 굵게 — 「어느 값을 썼나」를 표가 스스로 밝힌다. - if (slot.adopted) td.style.fontWeight = "700"; - // ⚠ **빈 칸은 「0원」이 아니라 「그 판에 그 품목이 없다」** — 막힌 자리로 표시한다. - put2(td, "slot_price", slot.price_krw === null ? "blocked" : undefined); - tr.append(td); - const page = document.createElement("td"); - page.textContent = slot.source_note; - page.className = "b09-left"; - put2(page, "slot_page"); - tr.append(page); - } - if (!appliedIsLastSlot) { - put(money(row.adopted_price_krw), false, "adopted_price_krw"); - put(row.adopted_slot ? (row.slots[row.adopted_slot - 1]?.name ?? "") : "", true); - } - put(row.note, true, "note"); - tbody.append(tr); - } - el.append(thead, tbody); - attachProvenance(el, sheet); - return el; -} - -/** 환율및기초자료 — 실무 시트 세 구획(환율·인건비·단가 및 재료비)을 차례대로. */ -function baseReferenceSections( - body: HTMLElement, - data: PriceSourcesDto["base_reference"], - projectId: string, - reload: () => void, - laborSheet?: ProvenanceSheet, - fuelSheet?: ProvenanceSheet, -): void { - body.append(head("환율및기초자료 — ① 환율")); - body.append(note(data.exchange.note)); - - body.append(head(`환율및기초자료 — ② 인건비 (${data.labor.rows.length})`)); - if (data.labor.rows.length === 0) { - body.append(note("아직 선 줄이 없습니다 — 값을 0 으로 채우지 않습니다.")); - } else { - body.append( - table( - ["코드번호", "직 종", "일 당", "시간당", "산 식"], - data.labor.rows.map((row) => [ - row.code, - row.name, - money(row.day_wage_krw), - money(row.hourly_krw), - row.formula, - ]), - [0, 1, 4], - ["code", "name", "day_wage_krw", "hourly_krw", "formula"], - laborSheet, - ), - ); - } - // 시간당이 소수로 남는 까닭을 밝힌다 — 안 밝히면 「덜 다듬은 값」으로 읽힌다. - body.append( - note( - "시간당은 나눈 값을 그대로 둡니다 — 여기서 원 단위로 자르면 기계 시간당 사용료가 " + - "조금씩 어긋납니다. 자르는 자리는 일위대가·내역서 쪽입니다.", - ), - ); - body.append(note(data.labor.note)); - - body.append(head("환율및기초자료 — ③ 단가 및 재료비")); - const fuel = data.fuel; - body.append( - table( - ["항 목", "단 가", "적용 범위", "기준일", "자료"], - [ - [ - "경유", - money(fuel.diesel_krw_per_l), - fuel.scopes.find((scope) => scope.key === fuel.scope)?.label || fuel.scope, - fuel.effective_date, - fuel.dataset_id, - ], - ], - [0, 2, 3, 4], - ["item", "price_krw", "scope", "effective_date", "dataset_id"], - fuelSheet, - ), - ); - - // ⚠ 확정 ⑮ — 전국/지역을 고르는 칸. 자료가 없는 것은 **고를 수 없게** 두고 - // 까닭을 곧바로 밝힌다. 고르게만 해 두고 값이 없으면 조용히 틀린 값이 선다. - // ⚠ 시도가 들어온 뒤로는 **한 칸에서 전국과 시도를 함께** 고른다 — 범위 칸과 시도 칸을 - // 따로 두면 「지역인데 시도를 안 고른 상태」가 생겨 무슨 값으로 섰는지 흐려진다. - const picker = document.createElement("div"); - picker.className = "b09-hint"; - picker.style.display = "flex"; - picker.style.alignItems = "center"; - picker.style.gap = "8px"; - picker.style.flexWrap = "wrap"; - const label = document.createElement("span"); - label.textContent = "유가 적용 범위"; - const select = document.createElement("select"); - const national = document.createElement("option"); - national.value = ""; - national.textContent = "전국 공시가"; - national.selected = !fuel.region; - select.append(national); - for (const region of fuel.regions) { - const option = document.createElement("option"); - option.value = region.code; - option.textContent = `${region.name} ${region.diesel_krw_per_l ?? ""}원/L`; - option.selected = region.code === fuel.region; - select.append(option); - } - select.disabled = fuel.regions.length === 0; - select.addEventListener("change", () => { - void saveFactorChoices(projectId, { fuel_region: select.value }) - .then(reload) - .catch((error: Error) => body.append(note(`⚠ ${error.message}`))); - }); - picker.append(label, select); - body.append(picker); - if (fuel.regions.length === 0) { - for (const scope of fuel.scopes) { - if (!scope.available && scope.why) body.append(note(`⚠ ${scope.label}: ${scope.why}`)); - } - } else { - body.append( - note( - fuel.region - ? `${fuel.region_name} 공시가로 서 있습니다 — 기계 연료비가 그 값으로 다시 섭니다.` - : "전국 공시가로 서 있습니다 — 현장 시도를 고르면 그 지역 값으로 바뀝니다.", - ), - ); - } - if (fuel.region_missing) body.append(note(`⚠ ${fuel.region_missing}`)); - body.append(note(fuel.note)); -} - -/** 기초자료 탭 아래쪽 — A9·A10 두 장. */ -export function drawPriceSourcesSections( - body: HTMLElement, - data: PriceSourcesDto, - projectId: string, - reload: () => void, -): void { - const comparison = data.material_comparison; - body.append(head(`자재단가대비표 (${comparison.rows.length})`)); - if (comparison.rows.length <= 1) { - body.append( - note( - "⚠ 사급 자재 카탈로그가 아직 서지 않아 줄이 거의 없습니다 — 재료비목록표와 같은 원인입니다.", - ), - ); - } - if (comparison.rows.length === 0) { - body.append(note("아직 선 줄이 없습니다 — 값을 0 으로 채우지 않습니다.")); - } else { - body.append( - comparisonTable( - comparison.slot_names, - comparison.rows, - data.provenance?.sheets?.material_comparison, - ), - ); - } - for (const text of comparison.notes) body.append(note(text)); - - baseReferenceSections( - body, - data.base_reference, - projectId, - reload, - data.provenance?.sheets?.base_reference_labor, - data.provenance?.sheets?.base_reference_fuel, - ); -} - -/** 두 표를 아직 못 받아왔을 때 — 화면을 비우지 않는다. */ -export function drawPriceSourcesPending(body: HTMLElement): void { - body.append(note("자재단가대비표·환율및기초자료를 불러오는 중입니다…")); -} - -/* ============================================================================= - * 산출 조건 — 품셈이 범위로 준 계수·장비 규격 (사용자 확정 ① 딸림 지시, 2026-09-09) - * - * 「값을 코드에 박고 끝내지 말 것 · 화면에 칸으로 세우고 근거를 보이고 바꿀 수 있게」 - * 라는 지시 그대로다. 고를 수 있는 것은 **원문에 적힌 값뿐**이고, 왜 그 값인지를 - * 칸 밑에 그대로 적는다. - * ========================================================================== */ - -export interface FactorOption { - key: string; - value?: string; - label: string; - note?: string; -} - -export interface RangeFactorRow { - key: string; - work_item_code: string; - work_item_name: string; - factor: string; - raw_cell: string; - chosen: string; - value: string; - is_default: boolean; - options: FactorOption[]; - basis: string[]; -} - -export interface MachineChoiceRow { - work_item_code: string; - work_item_name: string; - chosen: string; - default: string; - is_default: boolean; - source: string; - options: FactorOption[]; - basis: string[]; -} - -/** 공구손료·잡재료 칸 — **비어 있는 것이 기본**이고, 비면 안 붙는다(산림품셈 1-2-6). */ -export interface MiscMaterialRow { - percent: string; - min: string; - max: string; - basis: string[]; - /** 주재료비가 선 일위대가 수 — 0 이면 넣어도 붙을 밑수가 없다. */ - base_items: number; - base_note: string; -} - -/** 기계 수송비 칸 — 거리·도로 구분이 있어야 줄이 선다(산림품셈 10-4). */ -export interface TransportRow { - distance_km: string; - road: string; - roads: Array<{ key: string; label: string }>; - variants: Array<{ key: string; label: string; unit_price_krw: string }>; - basis: string[]; - notes: string[]; -} - -/** 품의 할인·할증 26계열 — 안 고르면 안 붙는다(산림품셈 1-4). */ -export interface LaborSurchargeRow { - chosen: Record; - total_percent: string; - reasons: string[]; - series: Array<{ - key: string; - title: string; - section: string; - source_note: string; - options: FactorOption[]; - }>; - basis: string[]; -} - -export interface FactorChoicesDto { - status: string; - ranges: RangeFactorRow[]; - machines: MachineChoiceRow[]; - misc_material?: MiscMaterialRow; - transport?: TransportRow; - labor_surcharge?: LaborSurchargeRow; - notes: string[]; -} - -export async function fetchFactorChoices(projectId: string): Promise { - const response = await fetch( - `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/factors`, - { credentials: "include" }, - ); - if (!response.ok) throw new Error(`factors ${response.status}`); - return (await response.json()) as FactorChoicesDto; -} - -export async function saveFactorChoices( - projectId: string, - body: { - range_factor_choices?: Record; - machine_choices?: Record; - misc_material_percent?: string; - fuel_region?: string; - transport_distance_km?: string; - transport_road?: string; - labor_surcharge?: Record; - }, -): Promise { - const response = await fetch( - `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/factors`, - { - method: "PUT", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }, - ); - if (!response.ok) { - const message = await response - .json() - .then((body: { message?: string }) => body.message ?? "") - .catch(() => ""); - throw new Error(message || `factors save ${response.status}`); - } -} - -/** - * 숫자 칸 하나 — **빈 칸이 기본**이다. [적용]을 눌러야 저장된다. - * - * ⚠ 고르는 칸(`picker`)과 달리 여기는 **사용자가 값을 짓는 자리**라 누를 때만 보낸다 — - * 타자 한 자마다 보내면 「2」를 치는 도중에 2% 로 저장돼 버린다. - */ -function percentBox( - label: string, - value: string, - placeholder: string, - onApply: (text: string) => void, -): HTMLElement { - const wrap = document.createElement("div"); - wrap.className = "b09-hint"; - wrap.style.display = "flex"; - wrap.style.alignItems = "center"; - wrap.style.gap = "8px"; - wrap.style.flexWrap = "wrap"; - - const name = document.createElement("span"); - name.style.fontWeight = "600"; - name.textContent = label; - - const input = document.createElement("input"); - input.type = "number"; - input.step = "0.1"; - input.min = "0"; - input.value = value; - input.placeholder = placeholder; - input.style.width = "72px"; - - const unit = document.createElement("span"); - unit.textContent = "%"; - - const apply = document.createElement("button"); - apply.type = "button"; - apply.textContent = "적용"; - apply.addEventListener("click", () => onApply(input.value.trim())); - - wrap.append(name, input, unit, apply); - return wrap; -} - -function picker( - label: string, - options: FactorOption[], - chosen: string, - onPick: (key: string) => void, -): HTMLElement { - const wrap = document.createElement("div"); - wrap.className = "b09-hint"; - wrap.style.display = "flex"; - wrap.style.alignItems = "center"; - wrap.style.gap = "8px"; - wrap.style.flexWrap = "wrap"; - - const name = document.createElement("span"); - name.style.fontWeight = "600"; - name.textContent = label; - - const select = document.createElement("select"); - for (const option of options) { - const item = document.createElement("option"); - item.value = option.key; - item.textContent = option.label; - item.selected = option.key === chosen; - select.append(item); - } - select.addEventListener("change", () => onPick(select.value)); - wrap.append(name, select); - return wrap; -} - -/** - * 산출 조건 구역 — 기초자료 탭 맨 위에 선다. - * - * ⚠ **기본값으로 돌고 있음을 숨기지 않는다** — 조용히 기본으로 돌면 사용자는 그것이 - * 잠정인 줄도 모른다(타설 방식에서 이미 겪은 자리). - */ -export function drawFactorChoices( - body: HTMLElement, - data: FactorChoicesDto, - projectId: string, - reload: () => void, -): void { - body.append(head("산출 조건 — 품셈이 한 값으로 안 준 자리")); - - for (const row of data.ranges) { - const title = `${row.work_item_name} 작업효율(${row.factor})`; - body.append( - picker(title, row.options, row.chosen, (key) => { - void saveFactorChoices(projectId, { range_factor_choices: { [row.key]: key } }).then( - reload, - ); - }), - ); - body.append( - note( - `품셈 원문은 「${row.raw_cell}」 — 지금 쓰는 값 ${row.value}` + - (row.is_default ? " (기본값으로 돌고 있습니다)" : " (사용자가 고른 값입니다)"), - ), - ); - for (const line of row.basis) body.append(note(line)); - } - - for (const row of data.machines) { - body.append( - picker(`${row.work_item_name} 장비 규격`, row.options, row.chosen, (key) => { - void saveFactorChoices(projectId, { - machine_choices: { [row.work_item_code]: key }, - }).then(reload); - }), - ); - body.append( - note( - row.source === "note" - ? "⚠ 이 장비는 품셈 표가 아니라 [주] 에 적혀 있어 공종 마스터가 아직 못 싣는 값입니다 — 이 칸이 그 자리를 대신합니다." - : "품셈 표가 정한 장비입니다." + - (row.is_default ? "" : " ⚠ 지금은 사용자가 바꾼 값으로 돌고 있습니다."), - ), - ); - for (const line of row.basis) body.append(note(line)); - } - - const misc = data.misc_material; - if (misc) { - body.append( - percentBox("공구손료·잡재료 (주재료비의)", misc.percent, "비움", (text) => { - void saveFactorChoices(projectId, { misc_material_percent: text }) - .then(reload) - .catch((error: Error) => { - body.append(note(`⚠ ${error.message}`)); - }); - }), - ); - body.append( - note( - misc.percent - ? `지금 ${misc.percent}% 로 붙고 있습니다 — 칸을 비우고 [적용]하면 도로 안 붙습니다.` - : `비어 있어 안 붙고 있습니다 — 넣을 수 있는 값은 ${misc.min}~${misc.max}% 입니다.`, - ), - ); - if (misc.base_note) body.append(note(misc.base_note)); - for (const line of misc.basis) body.append(note(line)); - } - - const transport = data.transport; - if (transport) { - body.append( - percentBox( - "기계 수송 거리 (인근 시·군·구청 → 현장, 편도 ㎞)", - transport.distance_km, - "비움", - (text) => { - void saveFactorChoices(projectId, { transport_distance_km: text }) - .then(reload) - .catch((error: Error) => body.append(note(`⚠ ${error.message}`))); - }, - ), - ); - const roadOptions: FactorOption[] = [ - { key: "", label: "안 고름" }, - ...transport.roads.map((road) => ({ key: road.key, label: road.label })), - ]; - body.append( - picker("수송 도로 구분", roadOptions, transport.road, (key) => { - void saveFactorChoices(projectId, { transport_road: key }) - .then(reload) - .catch((error: Error) => body.append(note(`⚠ ${error.message}`))); - }), - ); - for (const variant of transport.variants) { - body.append( - note( - variant.unit_price_krw - ? `${variant.label} — 회당 ${variant.unit_price_krw}원` - : `${variant.label} — 아직 안 섬`, - ), - ); - } - for (const line of transport.notes) body.append(note(`⚠ ${line}`)); - for (const line of transport.basis) body.append(note(line)); - body.append( - note( - "⚠ 단가는 「회당」입니다 — 몇 대를 몇 번 나르는지(회수)는 설계 입력이라 여기서 안 정합니다.", - ), - ); - } - - const surcharge = data.labor_surcharge; - if (surcharge) { - body.append(head("품의 할인·할증 (산림품셈 1-4) — 고른 것만 붙습니다")); - body.append( - note( - Number(surcharge.total_percent) === 0 - ? "지금 한 계열도 안 골라 한 원도 안 움직이고 있습니다." - : `지금 ${surcharge.total_percent}% 가 품에 붙고 있습니다 — ${surcharge.reasons.join(" · ")}`, - ), - ); - for (const line of surcharge.basis) body.append(note(line)); - for (const item of surcharge.series) { - const options: FactorOption[] = [{ key: "", label: "안 고름" }, ...item.options]; - body.append( - picker(`${item.section}`, options, surcharge.chosen[item.key] ?? "", (key) => { - void saveFactorChoices(projectId, { labor_surcharge: { [item.key]: key } }) - .then(reload) - .catch((error: Error) => body.append(note(`⚠ ${error.message}`))); - }), - ); - if (item.source_note) body.append(note(item.source_note)); - } - } - - for (const line of data.notes) body.append(note(line)); -} diff --git a/B09_Estimation/B09_Estimation_UI_BillRates.ts b/B09_Estimation/B09_Estimation_UI_BillRates.ts new file mode 100644 index 00000000..d717c70f --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_BillRates.ts @@ -0,0 +1,152 @@ +/* ============================================================================= + * B09_Estimation_UI_BillRates.ts + * 설계내역서 **할증율·일괄보정** 칸 — STmate `wBoqRate`·`wM_Rate` 본 (PLAN 12장 2차 ④) + * + * - 범위: 선택 항목만(줄마다 한 칸) / 내역서 전체(`*`) · 방식: 같은 비율 / 비목별(재·노·경) + * - 비목마다 절사·반올림 × 소수 0~4 자리(안 고르면 원 미만 절사). + * - ⚠ 여러 줄을 한꺼번에 바꾸므로 「할증 전부 되돌리기」를 둠 — 줄마다 ↺ 도 있음. + * - 화면은 고른 값만 보냄 — 금액은 서버가 다시 셈(내역 한 벌을 새로 받음). + * ========================================================================== */ + +import { showToast } from "@ui/ui_template_elements"; +import { L, el, userMark } from "./B09_Estimation_UI_Sheet"; +import { loadEdits, saveEdits, type BillRowDto, type EditChange } from "./B09_Estimation_UI_Store"; + +const SECTION = "bill_rates"; +const PARTS = [ + ["material", "B09_Sheet_Col_Material"], + ["labor", "B09_Sheet_Col_Labor"], + ["expense", "B09_Sheet_Col_Expense"], +] as const; + +function save(projectId: string, changes: EditChange[], reload: () => void): void { + if (changes.length === 0) return; + void saveEdits(projectId, changes) + .then(() => { + showToast(L("B09_Sheet_Saved"), "success"); + reload(); + }) + .catch((error: Error) => showToast(`${L("B09_Sheet_SaveFailed")} ${error.message}`, "error")); +} + +function numberBox(placeholder: string): HTMLInputElement { + const input = el("input", "b09s-edit-input"); + input.type = "text"; + input.inputMode = "decimal"; + input.placeholder = placeholder; + return input; +} + +function choice(options: Array<[string, string]>): HTMLSelectElement { + const select = el("select"); + for (const [value, label] of options) { + const option = el("option", "", label); + option.value = value; + select.append(option); + } + return select; +} + +/** 할증·일괄보정 칸 한 벌 — `selected` = 고른 줄의 할증 칸 이름. */ +export function rateForm( + projectId: string, + selected: Set, + reload: () => void, +): HTMLElement { + const box = el("div", "b09s-group"); + const scope = choice([ + ["selected", L("B09_Sheet_Rate_Selected")], + ["all", L("B09_Sheet_Rate_All")], + ]); + const way = choice([ + ["same", L("B09_Sheet_Rate_Same")], + ["parts", L("B09_Sheet_Rate_Parts")], + ]); + const same = numberBox("%"); + const parts = PARTS.map(([key, label]) => { + const rate = numberBox("%"); + const mode = choice([ + ["floor", L("B09_Sheet_Rate_Floor")], + ["round", L("B09_Sheet_Rate_Round")], + ]); + const digits = choice( + [0, 1, 2, 3, 4].map((n): [string, string] => [ + String(n), + `${L("B09_Sheet_Rate_Digits")} ${n}`, + ]), + ); + const line = el("span", "b09s-inline"); + line.append(el("span", "b09s-head", L(label)), rate, mode, digits); + return { key, rate, mode, digits, line }; + }); + + const apply = el("button", "b09s-undo", L("B09_Sheet_Apply")); + apply.type = "button"; + apply.addEventListener("click", () => { + const rounding = Object.fromEntries( + parts.map((part) => [part.key, { mode: part.mode.value, digits: Number(part.digits.value) }]), + ); + const value = + way.value === "same" + ? { all: same.value.trim() || "0", rounding } + : { + ...Object.fromEntries(parts.map((part) => [part.key, part.rate.value.trim() || "0"])), + rounding, + }; + const keys = scope.value === "all" ? ["*"] : [...selected]; + if (keys.length === 0) { + showToast(L("B09_Sheet_Rate_PickRows"), "info"); + return; + } + save( + projectId, + keys.map((key) => ({ section: SECTION, key, value })), + reload, + ); + }); + + const clear = el("button", "b09s-undo", L("B09_Sheet_Rate_ClearAll")); + clear.type = "button"; + clear.addEventListener("click", () => { + void loadEdits(projectId).then((stored) => + save( + projectId, + Object.keys(stored.edits[SECTION] ?? {}).map((key) => ({ + section: SECTION, + key, + value: null, + })), + reload, + ), + ); + }); + + const head = el("div", "b09s-bar"); + head.append( + el("span", "b09s-head", L("B09_Sheet_Rate")), + scope, + way, + same, + apply, + clear, + el("span", "b09s-hint", `${L("B09_Sheet_Rate_SelectedCount")} ${selected.size}`), + ); + const partLine = el("div", "b09s-bar"); + partLine.append(...parts.map((part) => part.line)); + box.append(head, partLine); + return box; +} + +/** 줄 비고의 할증 표시 — 그 줄 칸으로 얹혔으면 「사용자」+↺, 내역서 전체로 얹혔으면 표시만. */ +export function rateMark( + projectId: string, + row: BillRowDto, + reload: () => void, +): HTMLElement | null { + if (!row.rate) return null; + if (row.rate.source === "*") + return el("span", "b09s-badge b09s-badge--user", L("B09_Sheet_Rate_AllBadge")); + return userMark(() => + save(projectId, [{ section: SECTION, key: row.rate_key, value: null }], reload), + ); +} diff --git a/B09_Estimation/B09_Estimation_UI_Detail.ts b/B09_Estimation/B09_Estimation_UI_Detail.ts new file mode 100644 index 00000000..e5bb3d05 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Detail.ts @@ -0,0 +1,206 @@ +/* ============================================================================= + * B09_Estimation_UI_Detail.ts + * 호표 본표(일위대가표·단가산출근거·시간당 중기) 한 장 + **들어가기 자취** (PLAN 12장 1차) + * + * - ⭐ STmate 를 익숙하게 만드는 동작 = 타고 들어가기(브레인 판정). 내역 줄 → 제 N 호표 → + * 호표 안 줄(일위대가·단산·중기) → … 을 누를 때마다 자취가 쌓이고, 자취 글을 누르면 그 자리로 되돌아감. + * - 표 모양은 실무 `일위대가표`·`단가산출근거` 시트: 명칭·규격·수량·단위 · 합계/노무/재료/경비(단가·금액) · 비고. + * - ⚠ 합계 줄은 서버 값(호표 성분 소계 원 미만 절사) — 여기서 안 더함. + * ========================================================================== */ + +import type { B09TabContext } from "./B09_Estimation_UI_Shell_Types"; +import { + L, + el, + hint, + linkButton, + moneyCells, + numberCell, + quantity, + sheetHead, + sheetTable, +} from "./B09_Estimation_UI_Sheet"; +import { loadDetail, type DetailDto, type DetailRowDto } from "./B09_Estimation_UI_Store"; +import { + addRowForm, + qFormulaInput, + quantityInput, + rowControls, +} from "./B09_Estimation_UI_DetailEdit"; + +interface Crumb { + tab: string; + code: string; + label: string; +} + +const crumbs: Crumb[] = []; +let drilling = false; + +/** 본표 안 줄을 눌러 들어갈 때 — 자취를 이어 쌓음. 목록·내역에서 들어가면 자취가 새로 시작. */ +export function drill(ctx: B09TabContext, tab: string, code: string): void { + drilling = true; + ctx.open(tab, code); +} + +function visit(tab: string, code: string, label: string): void { + if (!drilling) crumbs.length = 0; + drilling = false; + const at = crumbs.findIndex((crumb) => crumb.tab === tab && crumb.code === code); + if (at >= 0) crumbs.length = at; + crumbs.push({ tab, code, label }); +} + +function trail(ctx: B09TabContext): HTMLElement { + const box = el("div", "b09s-trail"); + crumbs.forEach((crumb, index) => { + if (index > 0) box.append(el("span", "b09s-hint", "›")); + if (index === crumbs.length - 1) { + box.append(el("span", "b09s-title", crumb.label)); + return; + } + box.append(linkButton(crumb.label, () => drill(ctx, crumb.tab, crumb.code))); + }); + return box; +} + +/** 줄이 가리키는 곳 — 단산(D)은 단가산출근거 탭, 시간당 중기(X)는 중기 탭, 일위대가는 일위대가 탭. */ +function targetTab(row: DetailRowDto): string | null { + if (!row.drillable || !row.ref_code) return null; + if (row.kind === "price_basis") return "price_basis"; + return row.kind === "machine_hourly" ? "machine" : "unit_price"; +} + +/** 편집 칸을 켰는가 — 탭을 오가도 남음(본표 한 장 단위가 아니라 화면 단위). */ +let editing = false; + +interface EditHooks { + projectId: string; + reload: () => void; +} + +function detailRow(row: DetailRowDto, ctx: B09TabContext, hooks: EditHooks | null): HTMLElement { + const tr = el( + "tr", + row.edit?.removed ? "is-removed" : row.edit?.user || row.edit?.q_formula ? "is-user" : "", + ); + const edit = row.edit; + const quantityCell = + hooks && edit && editing && row.unit !== "%" ? el("td") : numberCell(quantity(row.quantity)); + if (hooks && edit && editing && row.unit !== "%") { + quantityCell.append(quantityInput(hooks.projectId, edit, row.quantity, hooks.reload)); + } + // 비율 줄(잡품·공구손료·제잡비)은 단가 칸에 **밑수**가 옴 — 「21 % × 주연료비 6,365」 꼴(실무 표). + const unit: [string, string, string, string] = [ + row.unit_total ?? "", + row.unit_labor ?? "", + row.unit_material ?? "", + row.unit_expense ?? "", + ]; + tr.append( + el("td", "", row.name), + el("td", "", row.spec), + quantityCell, + el("td", "", row.unit), + ...moneyCells(unit, [row.total, row.labor, row.material, row.expense]), + ); + const note = el("td", "b09s-note"); + if (hooks && edit && (editing || edit.user)) { + note.append(rowControls(hooks.projectId, edit, hooks.reload, editing)); + } + if (hooks && edit?.output && (editing || edit.q_formula)) { + note.append(qFormulaInput(hooks.projectId, edit, hooks.reload)); + } + const source = row.source_label || row.source || ""; + if (source) note.append(el("span", "b09s-hint", `[${source}] `)); + if (row.note) note.append(el("span", "b09s-formula", row.note)); + tr.append(note); + const tab = targetTab(row); + if (tab && row.ref_code) { + const code = row.ref_code; + tr.classList.add("is-clickable"); + tr.title = L("B09_Sheet_Drill"); + tr.addEventListener("click", () => drill(ctx, tab, code)); + } + return tr; +} + +function drawDetail( + ctx: B09TabContext, + box: HTMLElement, + detail: DetailDto, + label: string, + reload: () => void, +): void { + const title = el( + "div", + "b09s-title b09s-inline", + `${label} ${detail.name}${detail.spec ? ` · ${detail.spec}` : ""}`, + ); + if (detail.unit) title.append(el("span", "b09s-hint", ` (${detail.unit})`)); + const hooks = detail.editable && ctx.projectId ? { projectId: ctx.projectId, reload } : null; + if (hooks) { + // 2차 편집(PLAN 12장) — 구성행 수정 · Q 식 편집. 프로젝트 단위로 저장, 금액은 서버가 다시 셈. + const toggle = el( + "button", + "b09s-undo", + editing ? L("B09_Sheet_EditDone") : L("B09_Sheet_Edit"), + ); + toggle.type = "button"; + toggle.addEventListener("click", () => { + editing = !editing; + reload(); + }); + title.append(toggle); + } + const { wrap, tbody } = sheetTable( + sheetHead([ + L("B09_Sheet_Col_Name"), + L("B09_Sheet_Col_Spec"), + L("B09_Sheet_Col_Quantity"), + L("B09_Sheet_Col_Unit"), + ]), + ); + for (const row of detail.rows) tbody.append(detailRow(row, ctx, hooks)); + const sum = el("tr", "is-sum"); + sum.append(el("td", "", L("B09_Sheet_Sum")), el("td"), el("td"), el("td")); + sum.append( + ...moneyCells(null, [detail.total, detail.labor, detail.material, detail.expense]), + el("td"), + ); + tbody.append(sum); + box.append(title, wrap); + if (hooks && editing && detail.next_add_key) { + box.append(addRowForm(hooks.projectId, detail.code, detail.next_add_key, reload)); + } + if (detail.unattached_note) box.append(hint(detail.unattached_note.replace(/\*\*/g, ""), true)); + if (detail.known_gap_note) box.append(hint(detail.known_gap_note, true)); +} + +/** 본표 한 장을 `box` 에 — 자취를 쌓고 서버 본표를 받아 그림. */ +export function renderDetail( + ctx: B09TabContext, + box: HTMLElement, + tab: string, + code: string, + label: string, +): void { + if (!ctx.projectId) return; + visit(tab, code, label); + const projectId = ctx.projectId; + const load = (): void => { + box.replaceChildren(trail(ctx), hint(L("B09_Sheet_Loading"))); + loadDetail(projectId, code) + .then((detail) => { + box.replaceChildren(trail(ctx)); + drawDetail(ctx, box, detail, label, load); + }) + .catch((error: Error) => { + box.replaceChildren( + trail(ctx), + hint(`${L("B09_Sheet_LoadFailed")} ${error.message}`, true), + ); + }); + }; + load(); +} diff --git a/B09_Estimation/B09_Estimation_UI_DetailEdit.ts b/B09_Estimation/B09_Estimation_UI_DetailEdit.ts new file mode 100644 index 00000000..06296eb2 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_DetailEdit.ts @@ -0,0 +1,189 @@ +/* ============================================================================= + * B09_Estimation_UI_DetailEdit.ts + * 호표 본표 **편집** — 일위대가 구성행 수정(`wM_Edit_iLWi`) · 단가산출 Q 식 편집(`wM_Edit_San`) (PLAN 12장 2차) + * + * - 전부 프로젝트 단위(브레인 판정) — 칸 하나 고칠 때마다 서버에 고친 값만 보내고, 금액은 서버가 다시 셈. + * - 줄마다: 수량 칸 · ✕(줄 빼기) · D 의 기계 줄은 Q 식 칸 · 고친 줄 = 「사용자」 + ↺(계산값으로). + * - 아래: 줄 더하기 — 단가표 고르개(이름으로 찾기) + 수량. + * - ⚠ 화면이 값을 만들지 않음 — 입력한 글을 그대로 보내고, 받을 수 없는 값은 서버가 까닭과 함께 거절. + * ========================================================================== */ + +import { showToast } from "@ui/ui_template_elements"; +import { API_BASE_URL } from "@config/config_frontend"; +import { L, el, userMark } from "./B09_Estimation_UI_Sheet"; +import { saveEdits, type EditChange } from "./B09_Estimation_UI_Store"; + +/** 서버가 줄마다 붙이는 고친 값 표시. */ +export interface RowEditDto { + key: string; + user: boolean; + removed?: boolean; + added?: boolean; + was_quantity?: string; + output?: string; + q_formula?: string; +} + +interface SearchRowDto { + code: string; + name: string; + spec: string; + unit: string; + kind_label: string; +} + +export const ROWS_SECTION = "sheet_rows"; +export const Q_SECTION = "price_basis_q"; + +function save(projectId: string, changes: EditChange[], reload: () => void): void { + void saveEdits(projectId, changes) + .then(() => { + showToast(L("B09_Sheet_Saved"), "success"); + reload(); + }) + .catch((error: Error) => showToast(`${L("B09_Sheet_SaveFailed")} ${error.message}`, "error")); +} + +/** 수량 칸 — Enter·칸 나가기에 보냄(바뀐 때만). */ +export function quantityInput( + projectId: string, + edit: RowEditDto, + quantity: string, + reload: () => void, +): HTMLElement { + const input = el("input", "b09s-edit-input"); + input.type = "text"; + input.inputMode = "decimal"; + input.value = quantity; + const send = (): void => { + const text = input.value.trim(); + if (text === quantity) return; + save(projectId, [{ section: ROWS_SECTION, key: edit.key, value: { quantity: text } }], reload); + }; + input.addEventListener("click", (event) => event.stopPropagation()); + input.addEventListener("keydown", (event) => { + if (event.key === "Enter") input.blur(); + }); + input.addEventListener("change", send); + return input; +} + +/** 줄 끝 조작 — ✕(빼기) · 고친 줄이면 「사용자」 + ↺. */ +export function rowControls( + projectId: string, + edit: RowEditDto, + reload: () => void, + editing: boolean, +): HTMLElement { + const box = el("span", "b09s-inline"); + if (editing && !edit.removed && !edit.added) { + const remove = el("button", "b09s-undo", "✕"); + remove.type = "button"; + remove.title = L("B09_Sheet_RemoveRow"); + remove.addEventListener("click", (event) => { + event.stopPropagation(); + save(projectId, [{ section: ROWS_SECTION, key: edit.key, value: { removed: true } }], reload); + }); + box.append(remove); + } + if (edit.user) { + box.append( + userMark(() => + save(projectId, [{ section: ROWS_SECTION, key: edit.key, value: null }], reload), + ), + ); + } + return box; +} + +/** D 기계 줄의 Q 식 칸 — 식을 글로 넣으면 서버가 셈 → 소수 2자리 확정 → 줄 수량 = 몫 ÷ Q. */ +export function qFormulaInput( + projectId: string, + edit: RowEditDto, + reload: () => void, +): HTMLElement { + const box = el("div", "b09s-inline"); + const input = el("input", "b09s-edit-input b09s-edit-formula"); + input.type = "text"; + input.placeholder = `Q = ${edit.output ?? ""}`; + input.value = edit.q_formula ?? ""; + input.addEventListener("click", (event) => event.stopPropagation()); + input.addEventListener("keydown", (event) => { + if (event.key === "Enter") input.blur(); + }); + input.addEventListener("change", () => { + const text = input.value.trim(); + const value = text ? { q_formula: text } : null; + save(projectId, [{ section: Q_SECTION, key: edit.key, value }], reload); + }); + box.append(el("span", "b09s-head", "Q"), input); + if (edit.q_formula) { + box.append( + userMark(() => save(projectId, [{ section: Q_SECTION, key: edit.key, value: null }], reload)), + ); + } + return box; +} + +/** 줄 더하기 — 단가표에서 이름으로 찾아 고르고 수량을 넣음. */ +export function addRowForm( + projectId: string, + code: string, + nextKey: string, + reload: () => void, +): HTMLElement { + const box = el("div", "b09s-bar"); + const search = el("input", "b09s-edit-input b09s-edit-formula"); + search.type = "text"; + search.placeholder = L("B09_Sheet_SearchTitle"); + const pick = el("select"); + const amount = el("input", "b09s-edit-input"); + amount.type = "text"; + amount.inputMode = "decimal"; + amount.placeholder = L("B09_Sheet_Col_Quantity"); + const add = el("button", "b09s-undo", L("B09_Sheet_AddRow")); + add.type = "button"; + let timer = 0; + search.addEventListener("input", () => { + window.clearTimeout(timer); + timer = window.setTimeout(() => { + const query = search.value.trim(); + if (!query) return; + void fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/edits/search?q=${encodeURIComponent(query)}&parent=${encodeURIComponent(code)}`, + { credentials: "include" }, + ) + .then((response) => response.json() as Promise<{ rows: SearchRowDto[] }>) + .then((data) => { + pick.replaceChildren( + ...data.rows.map((row) => { + const option = el( + "option", + "", + `[${row.kind_label}] ${row.name} ${row.spec} (${row.unit})`.trim(), + ); + option.value = row.code; + return option; + }), + ); + }) + .catch(() => pick.replaceChildren()); + }, 250); + }); + add.addEventListener("click", () => { + if (!pick.value || !amount.value.trim()) return; + save( + projectId, + [ + { + section: ROWS_SECTION, + key: nextKey, + value: { ref: pick.value, quantity: amount.value.trim() }, + }, + ], + reload, + ); + }); + box.append(el("span", "b09s-head", L("B09_Sheet_AddRow")), search, pick, amount, add); + return box; +} diff --git a/B09_Estimation/B09_Estimation_UI_Factors.ts b/B09_Estimation/B09_Estimation_UI_Factors.ts new file mode 100644 index 00000000..dd64b8e0 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Factors.ts @@ -0,0 +1,295 @@ +/* ============================================================================= + * B09_Estimation_UI_Factors.ts + * 산출 조건 — 품셈이 범위로 준 계수·장비 규격·공구손료·수송·품 할증 (사용자 확정 ① 딸림 지시, 2026-09-09) + * + * - 옛 `B09_Estimation_UI_BaseData.ts` 의 산출 조건 구역을 그대로 옮김(PLAN 12장 옛 탭 옮기기). + * - ⚠ **계산 입력**이다 — 고르면 PUT `/estimation/factors` 로 저장되고 단가가 다시 섬. + * 고를 수 있는 것은 원문에 적힌 값뿐이고, 왜 그 값인지를 칸 밑에 그대로 적음. + * - ⚠ 기본값으로 돌고 있음을 숨기지 않음 — 조용히 기본으로 돌면 잠정인 줄도 모름. + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { el } from "./B09_Estimation_UI_Sheet"; +import { head, note } from "./B09_Estimation_UI_Table"; + +export interface FactorOption { + key: string; + value?: string; + label: string; + note?: string; +} + +interface RangeFactorRow { + key: string; + work_item_code: string; + work_item_name: string; + factor: string; + raw_cell: string; + chosen: string; + value: string; + is_default: boolean; + options: FactorOption[]; + basis: string[]; +} + +interface MachineChoiceRow { + work_item_code: string; + work_item_name: string; + chosen: string; + default: string; + is_default: boolean; + source: string; + options: FactorOption[]; + basis: string[]; +} + +/** 공구손료·잡재료 칸 — **비어 있는 것이 기본**이고, 비면 안 붙음(산림품셈 1-2-6). */ +interface MiscMaterialRow { + percent: string; + min: string; + max: string; + basis: string[]; + base_items: number; + base_note: string; +} + +/** 기계 수송비 칸 — 거리·도로 구분이 있어야 줄이 섬(산림품셈 10-4). */ +interface TransportRow { + distance_km: string; + road: string; + roads: Array<{ key: string; label: string }>; + variants: Array<{ key: string; label: string; unit_price_krw: string }>; + basis: string[]; + notes: string[]; +} + +/** 품의 할인·할증 26계열 — 안 고르면 안 붙음(산림품셈 1-4). */ +interface LaborSurchargeRow { + chosen: Record; + total_percent: string; + reasons: string[]; + series: Array<{ + key: string; + title: string; + section: string; + source_note: string; + options: FactorOption[]; + }>; + basis: string[]; +} + +export interface FactorChoicesDto { + ranges: RangeFactorRow[]; + machines: MachineChoiceRow[]; + misc_material?: MiscMaterialRow; + transport?: TransportRow; + labor_surcharge?: LaborSurchargeRow; + notes: string[]; +} + +export async function fetchFactorChoices(projectId: string): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/factors`, + { credentials: "include" }, + ); + if (!response.ok) throw new Error(`factors ${response.status}`); + return (await response.json()) as FactorChoicesDto; +} + +export async function saveFactorChoices( + projectId: string, + body: { + range_factor_choices?: Record; + machine_choices?: Record; + misc_material_percent?: string; + fuel_region?: string; + transport_distance_km?: string; + transport_road?: string; + labor_surcharge?: Record; + }, +): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/factors`, + { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); + if (!response.ok) { + const message = await response + .json() + .then((payload: { message?: string }) => payload.message ?? "") + .catch(() => ""); + throw new Error(message || `factors save ${response.status}`); + } +} + +/** + * 숫자 칸 하나 — **빈 칸이 기본**. [적용]을 눌러야 저장됨. + * ⚠ 고르는 칸과 달리 사용자가 값을 짓는 자리라 누를 때만 보냄(「2」를 치는 도중에 2% 로 저장되지 않게). + */ +function percentBox( + label: string, + value: string, + placeholder: string, + onApply: (text: string) => void, +): HTMLElement { + const wrap = el("div", "b09s-hint b09s-inline"); + const input = el("input"); + input.type = "number"; + input.step = "0.1"; + input.min = "0"; + input.value = value; + input.placeholder = placeholder; + input.style.width = "72px"; + const apply = el("button", "", "적용"); + apply.type = "button"; + apply.addEventListener("click", () => onApply(input.value.trim())); + wrap.append(el("span", "b09s-head", label), input, el("span", "", "%"), apply); + return wrap; +} + +export function picker( + label: string, + options: FactorOption[], + chosen: string, + onPick: (key: string) => void, +): HTMLElement { + const wrap = el("div", "b09s-hint b09s-inline"); + const select = el("select"); + for (const option of options) { + const item = el("option", "", option.label); + item.value = option.key; + item.selected = option.key === chosen; + select.append(item); + } + select.addEventListener("change", () => onPick(select.value)); + wrap.append(el("span", "b09s-head", label), select); + return wrap; +} + +/** 산출 조건 구역 — 기초자료 탭 맨 위. `reload` = 저장 뒤 다시 받아 그리기. */ +export function drawFactorChoices( + body: HTMLElement, + data: FactorChoicesDto, + projectId: string, + reload: () => void, +): void { + const save = (payload: Parameters[1]): void => { + void saveFactorChoices(projectId, payload) + .then(reload) + .catch((error: Error) => body.append(note(`⚠ ${error.message}`))); + }; + body.append(head("산출 조건 — 품셈이 한 값으로 안 준 자리")); + + for (const row of data.ranges) { + body.append( + picker(`${row.work_item_name} 작업효율(${row.factor})`, row.options, row.chosen, (key) => + save({ range_factor_choices: { [row.key]: key } }), + ), + ); + body.append( + note( + `품셈 원문은 「${row.raw_cell}」 — 지금 쓰는 값 ${row.value}` + + (row.is_default ? " (기본값으로 돌고 있습니다)" : " (사용자가 고른 값입니다)"), + ), + ); + for (const line of row.basis) body.append(note(line)); + } + + for (const row of data.machines) { + body.append( + picker(`${row.work_item_name} 장비 규격`, row.options, row.chosen, (key) => + save({ machine_choices: { [row.work_item_code]: key } }), + ), + ); + body.append( + note( + row.source === "note" + ? "⚠ 이 장비는 품셈 표가 아니라 [주] 에 적혀 있어 공종 마스터가 아직 못 싣는 값입니다 — 이 칸이 그 자리를 대신합니다." + : "품셈 표가 정한 장비입니다." + + (row.is_default ? "" : " ⚠ 지금은 사용자가 바꾼 값으로 돌고 있습니다."), + ), + ); + for (const line of row.basis) body.append(note(line)); + } + + const misc = data.misc_material; + if (misc) { + body.append( + percentBox("공구손료·잡재료 (주재료비의)", misc.percent, "비움", (text) => + save({ misc_material_percent: text }), + ), + ); + body.append( + note( + misc.percent + ? `지금 ${misc.percent}% 로 붙고 있습니다 — 칸을 비우고 [적용]하면 도로 안 붙습니다.` + : `비어 있어 안 붙고 있습니다 — 넣을 수 있는 값은 ${misc.min}~${misc.max}% 입니다.`, + ), + ); + if (misc.base_note) body.append(note(misc.base_note)); + for (const line of misc.basis) body.append(note(line)); + } + + const transport = data.transport; + if (transport) { + body.append( + percentBox( + "기계 수송 거리 (인근 시·군·구청 → 현장, 편도 ㎞)", + transport.distance_km, + "비움", + (text) => save({ transport_distance_km: text }), + ), + ); + const roadOptions: FactorOption[] = [ + { key: "", label: "안 고름" }, + ...transport.roads.map((road) => ({ key: road.key, label: road.label })), + ]; + body.append( + picker("수송 도로 구분", roadOptions, transport.road, (key) => save({ transport_road: key })), + ); + for (const variant of transport.variants) { + body.append( + note( + variant.unit_price_krw + ? `${variant.label} — 회당 ${variant.unit_price_krw}원` + : `${variant.label} — 아직 안 섬`, + ), + ); + } + for (const line of transport.notes) body.append(note(`⚠ ${line}`)); + for (const line of transport.basis) body.append(note(line)); + body.append( + note( + "⚠ 단가는 「회당」입니다 — 몇 대를 몇 번 나르는지(회수)는 설계 입력이라 여기서 안 정합니다.", + ), + ); + } + + const surcharge = data.labor_surcharge; + if (surcharge) { + body.append(head("품의 할인·할증 (산림품셈 1-4) — 고른 것만 붙습니다")); + body.append( + note( + Number(surcharge.total_percent) === 0 + ? "지금 한 계열도 안 골라 한 원도 안 움직이고 있습니다." + : `지금 ${surcharge.total_percent}% 가 품에 붙고 있습니다 — ${surcharge.reasons.join(" · ")}`, + ), + ); + for (const line of surcharge.basis) body.append(note(line)); + for (const item of surcharge.series) { + const options: FactorOption[] = [{ key: "", label: "안 고름" }, ...item.options]; + body.append( + picker(`${item.section}`, options, surcharge.chosen[item.key] ?? "", (key) => + save({ labor_surcharge: { [item.key]: key } }), + ), + ); + if (item.source_note) body.append(note(item.source_note)); + } + } + + for (const line of data.notes) body.append(note(line)); +} diff --git a/B09_Estimation/B09_Estimation_UI_MachineExpense.ts b/B09_Estimation/B09_Estimation_UI_MachineExpense.ts new file mode 100644 index 00000000..cce33926 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_MachineExpense.ts @@ -0,0 +1,122 @@ +/* ============================================================================= + * B09_Estimation_UI_MachineExpense.ts + * 각종 중기경비계산서 — 기종마다 한 장(별표2 (5)(가) 아홉째) · 중기 탭 아래에 붙음 + * + * - 옛 `B09_Estimation_UI_BaseData.ts` 의 중기경비계산서를 그대로 옮김(PLAN 12장 옛 탭 옮기기). + * - ⚠ 목록표가 「얼마」라면 이 장은 **왜 그 값인가** — 계산 과정을 감추지 않음. + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { el } from "./B09_Estimation_UI_Sheet"; +import { head, infoTable, money, note } from "./B09_Estimation_UI_Table"; + +export interface MachineExpenseDto { + summary: string; + notes: string[]; + sheets: Array<{ + machine_code: string; + name: string; + spec: string; + price_thousand_krw: string | null; + economic_life_hours: number | null; + annual_standard_hours: number | null; + depreciation_coefficient: number | null; + maintenance_coefficient: number | null; + management_coefficient: number | null; + loss_coefficient: number | null; + loss_krw_per_hour: string | null; + fuel_liters_per_hour: string | null; + fuel_price_per_liter: string | null; + fuel_scope: string; + misc_material_percent: string | null; + operator_code: string; + operator_daily_wage: string | null; + operator_krw_per_hour: string | null; + material_krw: string | null; + labor_krw: string | null; + expense_krw: string | null; + total_krw: string | null; + variant: string; + attachment: boolean; + attachment_note: string; + gaps: string[]; + }>; +} + +export async function fetchMachineExpense(projectId: string): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/machine-expense`, + { credentials: "include" }, + ); + if (!response.ok) throw new Error(`machine-expense ${response.status}`); + return (await response.json()) as MachineExpenseDto; +} + +/** 기종 한 장 — 손료·운전경비·시간당 사용료를 차례로. */ +function machineExpenseSheet(sheet: MachineExpenseDto["sheets"][number]): HTMLElement { + const box = el("div", "b09s-group"); + box.append( + el( + "div", + "b09s-head", + `${sheet.machine_code} ${sheet.name} ${sheet.spec}`.trim() + + (sheet.variant ? ` — ${sheet.variant}` : ""), + ), + ); + const coefficient = (value: number | null) => (value === null ? "—" : String(value)); + box.append( + infoTable( + ["구 분", "내 용", "값"], + [ + ["① 손료", "취득가격(천원)", money(sheet.price_thousand_krw)], + [ + "", + "내용시간 / 연간표준가동시간", + `${coefficient(sheet.economic_life_hours)} / ${coefficient(sheet.annual_standard_hours)}`, + ], + [ + "", + "상각비·정비비·관리비 계수 (10⁻⁷)", + `${coefficient(sheet.depreciation_coefficient)} + ${coefficient(sheet.maintenance_coefficient)} + ${coefficient(sheet.management_coefficient)} = ${coefficient(sheet.loss_coefficient)}`, + ], + ["", "시간당 손료(원)", money(sheet.loss_krw_per_hour)], + [ + "② 운전경비", + `주연료(L/hr) × 유가(${sheet.fuel_scope})`, + `${sheet.fuel_liters_per_hour ?? "—"} × ${money(sheet.fuel_price_per_liter)}`, + ], + ["", "잡재료(주연료의 %)", sheet.misc_material_percent ?? "—"], + [ + "", + `조종원(${sheet.operator_code || "—"}) 일당 → 시간당`, + `${money(sheet.operator_daily_wage)} → ${money(sheet.operator_krw_per_hour)}`, + ], + [ + "③ 시간당 사용료", + "재료비 / 노무비 / 경비", + `${money(sheet.material_krw)} / ${money(sheet.labor_krw)} / ${money(sheet.expense_krw)}`, + ], + ["", "합 계", money(sheet.total_krw)], + ], + [0, 1], + ), + ); + if (sheet.variant) { + box.append( + note( + "같은 기종이라도 조합 사용이면 잡재료가 16% 로 줄어 재료비가 달라집니다 —" + + " 그래서 층이 따로 섭니다(건설품셈 제8장 [주]⑤).", + ), + ); + } + if (sheet.attachment_note) box.append(note(sheet.attachment_note)); + for (const gap of sheet.gaps) box.append(note(`⚠ ${gap}`)); + return box; +} + +export function drawMachineExpense(body: HTMLElement, data: MachineExpenseDto): void { + body.append(head(`각종 중기경비계산서 (${data.sheets.length})`)); + body.append(note(data.summary)); + for (const line of data.notes) body.append(note(line)); + for (const sheet of data.sheets) body.append(machineExpenseSheet(sheet)); +} diff --git a/B09_Estimation/B09_Estimation_UI_Page.ts b/B09_Estimation/B09_Estimation_UI_Page.ts deleted file mode 100644 index 984a7b14..00000000 --- a/B09_Estimation/B09_Estimation_UI_Page.ts +++ /dev/null @@ -1,1561 +0,0 @@ -/* ============================================================================= - * B09_Estimation_UI_Page.ts - * 로그인 후 09: 6차 워크플로우 (원가계산) - * - * 화면 규칙 (PLAN 8-13 · 화면 기획) - * - 3단 레이아웃: 상단 타이틀·스텝바 / 좌측 고정폭 입력 / 우측 탭 + 표. - * - 원가계산서 줄은 **「비목 · 금액 · 요율 · 산출근거」 네 칸**을 다 보인다. - * 결과 숫자만 보이면 설계자가 검산을 못 한다. - * - **안전관리비는 A·B 두 줄을 나란히 두고 채택한 쪽을 표시**한다 - * (실무 `안전관리비검토` 시트와 같은 서식, PLAN 8-12). - * - **어느 판 요율로 계산했는지**를 좌측에 남긴다 — 재현성(PLAN 9-2). - * - 이윤 조정액은 **설계자가 직접 넣을 때만** 반영. 목표 도급액을 넣으면 필요액을 - * 보여만 준다 (★법대로 PLAN 8-10). - * ========================================================================== */ - -import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; -import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; -import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; -import { attachCollapsible } from "@ui/ui_template_collapsible"; -import { - attachProvenance, - createProvenanceToggle, - markProvenanceCell, - type ProvenancePayload, - type ProvenanceSheet, -} from "@ui/ui_template_provenance"; -import { - drawBaseDataTab, - drawFactorChoices, - drawBasisSheet, - drawDesignDocTab, - drawMachineExpense, - drawMachineTab, - drawPriceSourcesPending, - drawPriceSourcesSections, - fetchBaseData, - fetchBasisSheet, - fetchDesignDocIndex, - fetchMachineExpense, - fetchFactorChoices, - fetchPriceSources, - type BaseDataDto, - type BasisSheetDto, - type DesignDocDto, - type MachineExpenseDto, - type FactorChoicesDto, - type PriceSourcesDto, -} from "./B09_Estimation_UI_BaseData"; -import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; -import { workflowSteps } from "../A00_Common/b_page_scaffold"; -import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav"; - -function L(key: keyof typeof ui_locales): string { - return ui_locales[key][currentLanguageIndex]; -} - -/* ----------------------------------------------------------------------------- - * 타입 — 라우터 응답과 1:1 - * -------------------------------------------------------------------------- */ - -interface CostLineDto { - key: string; - name: string; - base_label: string; - base_amount_krw: string; - rate_percent: string | null; - flat_amount_krw: string; - amount_krw: string; - formula_text: string; - note: string; -} - -interface CostSheetDto { - status: string; - direct_cost_source: "manual" | "quantities"; - missing_unit_prices: string[]; - lines: CostLineDto[]; - totals: Record; - rate_version: { dataset_id: string; effective_date: string; sha256: string }; - notes: string[]; - suggested_profit_adjustment_krw?: string; - /** 근거 사전 — **개발환경에서만** 온다. 없으면 호버·등급색이 통째로 안 붙는다. */ - provenance?: ProvenancePayload; -} - -interface UnitPriceRow { - code: string; - name: string; - spec: string; - unit: string; - material: string; - labor: string; - expense: string; - total: string; -} - -interface UnitPriceListDto { - status: string; - summary: { - titles: number; - unit_prices: number; - machine_hourly: number; - notes: string[]; - // 표본이 얇은 노임 — 금액은 서 있고 「조사현장이 적다」는 사실만 알린다. - labor_reliability: Array<{ code: string; name: string; flag: string; why: string }>; - }; - rows: UnitPriceRow[]; - provenance?: ProvenancePayload; -} - -interface UnitPriceDetailRow extends UnitPriceRow { - ref_code: string; - source_label: string; - source_index: number; - drillable: boolean; - quantity: string; - unit_total: string; - note: string; -} - -interface UnitPriceDetailDto { - status: string; - precise_total: string; - code: string; - name: string; - spec: string; - unit: string; - material: string; - labor: string; - expense: string; - total: string; - sum_matches: boolean; - provenance?: ProvenancePayload; - /** 품셈 표에 있는데 아직 안 붙은 줄 — 있으면 이 단가는 **붙은 줄만의 값**이다. */ - unattached: string[]; - unattached_note: string; - rows: UnitPriceDetailRow[]; -} - -/** 좌측 입력 상태 — 화면이 들고 있는 값. 저장은 [확정] 때만. */ -interface CostFormState { - direct_material_krw: string; - direct_labor_krw: string; - direct_expense_krw: string; - duration_days: string; - owner_supplied_material_krw: string; - procurement_fee_krw: string; - profit_adjustment_krw: string; - target_contract_amount_krw: string; - /** 「공종코드=수량」 한 줄씩. 비어 있으면 위 직접비 3칸을 그대로 쓴다. */ - quantities_text: string; -} - -const INITIAL_FORM: CostFormState = { - direct_material_krw: "0", - direct_labor_krw: "0", - direct_expense_krw: "0", - duration_days: "183", - owner_supplied_material_krw: "0", - procurement_fee_krw: "0", - profit_adjustment_krw: "0", - target_contract_amount_krw: "", - quantities_text: "", -}; - -/** 총계 성격의 줄 — 표에서 굵게 띄운다. */ -const TOTAL_KEYS = new Set([ - "material_cost", - "labor_cost", - "expense", - "net_construction_cost", - "total_cost", - "contract_amount", - "grand_total", -]); - -/* ----------------------------------------------------------------------------- - * 스타일 — 공통 토큰만 사용 (frontend.md §1 하드코딩 금지) - * -------------------------------------------------------------------------- */ - -const STYLE_ID = "b09-estimation-styles"; - -function injectStyles(): void { - if (document.getElementById(STYLE_ID)) return; - const style = document.createElement("style"); - style.id = STYLE_ID; - style.textContent = ` -.b09-panel { display: flex; flex-direction: column; gap: var(--space-md, 12px); } -.b09-panel__group { display: flex; flex-direction: column; gap: var(--space-xs, 4px); } -/* 좌측 패널 상자 — B04~B07 과 같은 꼴(테두리는 공용 .ui-sidebar-section 이 전담). - .ui-sidebar-section 이 붙은 것만 집어 본문 기초자료 표의 같은 클래스는 안 건드린다. */ -.b09-panel__group.ui-sidebar-section { - margin: 0; - padding: calc(var(--spacing-8) + var(--spacing-4)); - border-radius: var(--radius-cards); - background-color: var(--color-surface-raised); -} -.b09-panel__legend { - font-size: var(--font-size-xs, 12px); letter-spacing: .06em; - color: var(--color-text-secondary); text-transform: uppercase; -} -.b09-panel__readonly { - font-size: var(--font-size-xs, 12px); color: var(--color-text-secondary); - display: flex; justify-content: space-between; gap: var(--space-sm, 8px); - border-bottom: 1px solid var(--color-border); padding: 2px 0; -} -.b09-hint { font-size: var(--font-size-xs, 12px); color: var(--color-text-secondary); } -/* 표본이 얇은 노임 — 막는 것이 아니라 눈에 띄기만 하면 된다. */ -.b09-hint--warn { color: var(--color-warning-text, #8a5a00); } - -/* ⚠ min-width: 0 이 빠지면 **표가 넓은 만큼 이 칸이 통째로 밀려 나간다**(2026-09-08 실측: - 창 620 에서 1,190px 넘침). flex 자식의 기본 최소폭이 auto 라 안쪽 표의 최소폭 - (칸이 nowrap)을 그대로 물기 때문이다. 0 으로 끊어야 아래 .b09-sheet 의 - overflow: auto 가 제 몫을 해서 **표만 제 안에서 가로로 넘어간다.** - ⚠ 이 주석 안에 백틱을 쓰지 말 것 — 이 블록은 템플릿 문자열 안이라 거기서 끊긴다. */ -.b09-main { display: flex; flex-direction: column; gap: var(--space-sm, 8px); height: 100%; min-height: 0; min-width: 0; } -.b09-tabs { display: flex; flex-wrap: wrap; gap: 4px; border-bottom: 1px solid var(--color-border); padding-bottom: 6px; } -.b09-tab { - font-size: var(--font-size-xs, 12px); padding: 2px 8px; cursor: pointer; - border: 1px solid var(--color-border); background: transparent; color: var(--color-text-secondary); -} -.b09-tab.is-active { border-color: var(--color-primary); color: var(--color-primary); background: var(--color-surface); } -.b09-tab:disabled { cursor: not-allowed; opacity: .55; } - -.b09-sheet { overflow: auto; min-height: 0; min-width: 0; flex: 1; } -/* ⚠ 이 클래스가 **감싸는 칸(div)에 붙는 자리와 표(table)에 바로 붙는 자리**가 둘 다 있다 - (내역서·자재대는 표에 직접 붙인다). 표는 그대로 두면 overflow 가 안 먹어 **표 폭만큼 - 바깥 칸을 밀어낸다**(2026-09-08 실측: 창 620 에서 1,190px). 블록으로 바꾸면 제 안에서 - 가로로 넘어가고 바깥은 안 밀린다. 표 안쪽(thead·tbody)의 칸 배치는 그대로다. */ -table.b09-sheet { display: block; overflow-x: auto; max-width: 100%; } -.b09-sheet table { width: 100%; border-collapse: collapse; font-size: var(--font-size-sm, 13px); } -.b09-sheet th, .b09-sheet td { - border-bottom: 1px solid var(--color-border); padding: 4px 8px; text-align: right; - white-space: nowrap; font-variant-numeric: tabular-nums; -} -.b09-sheet th { text-align: center; color: var(--color-text-secondary); font-weight: 600; } -.b09-sheet td.b09-left, .b09-sheet th.b09-left { text-align: left; white-space: normal; } -.b09-sheet tr.is-total td { font-weight: 600; background: var(--color-surface); } -.b09-sheet tr.is-adopted td { background: var(--color-surface); } -.b09-sheet tr.is-dropped td { color: var(--color-text-secondary); text-decoration: line-through; } -.b09-qty { min-height: 64px; font-family: monospace; font-size: var(--font-size-xs, 12px); } -.b09-clickable { cursor: pointer; } -.b09-clickable:hover td { background: var(--color-surface); } -.b09-up-list { max-height: 45%; } -.b09-up-detail { border-top: 2px solid var(--color-border); padding-top: 6px; } -.b09-empty { padding: var(--space-lg, 16px); color: var(--color-text-secondary); font-size: var(--font-size-sm, 13px); } -`; - document.head.append(style); -} - -/* ----------------------------------------------------------------------------- - * 표 그리기 - * -------------------------------------------------------------------------- */ - -function formatWon(value: string): string { - const n = Number(value); - if (!Number.isFinite(n)) return value; - return n.toLocaleString("ko-KR"); -} - -/** - * 줄 사유 조각을 줄에 실어 둔다 — 카드가 꺼내 쓴다. - * - * ⚠ 조각마다 **닿는 열**이 함께 온다. 줄에 달렸다고 모든 칸에 띄우면 - * 「금액」 카드에 「갈래 근거…」 가 떠서 읽는 사람을 속인다(2026-09-12 B08 실측). - */ -function stashRowNotes(tr: HTMLElement, notes?: Array<{ column: string; text: string }>): void { - if (notes?.length) tr.dataset.provNotes = JSON.stringify(notes); -} - -/** 그 칸에 **닿는** 줄 사유만 돌려준다. 열 키가 빈 조각은 줄 전체에 걸리는 사유다. */ -function rowNotesFor(cell: HTMLElement, columnKey: string): string[] { - const raw = cell.closest("tr")?.dataset.provNotes; - if (!raw) return []; - try { - return (JSON.parse(raw) as Array<{ column: string; text: string }>) - .filter((note) => note.column === "" || note.column === columnKey) - .map((note) => note.text); - } catch { - return []; - } -} - -/** 칸에 열 키·등급을 심는다 — **사전에 없는 열은 아무 일도 안 한다**(빈 카드 방지). */ -function mark(cell: HTMLElement, sheet: ProvenanceSheet | undefined, columnKey: string): void { - const column = sheet?.columns[columnKey]; - if (column) markProvenanceCell(cell, columnKey, column.tier); -} - -function buildCostSheetTable(sheet: CostSheetDto): HTMLElement { - const prov = sheet.provenance?.sheets?.cost_sheet; - const wrap = document.createElement("div"); - wrap.className = "b09-sheet"; - - const table = document.createElement("table"); - const thead = document.createElement("thead"); - const headRow = document.createElement("tr"); - const headers: Array<[string, boolean]> = [ - [L("B09_Estimation_Col_Item"), true], - [L("B09_Estimation_Col_Amount"), false], - [L("B09_Estimation_Col_Rate"), false], - [L("B09_Estimation_Col_Basis"), true], - [L("B09_Estimation_Col_Note"), true], - ]; - for (const [text, left] of headers) { - const th = document.createElement("th"); - th.textContent = text; - if (left) th.className = "b09-left"; - headRow.append(th); - } - thead.append(headRow); - table.append(thead); - - const tbody = document.createElement("tbody"); - for (const line of sheet.lines) { - const tr = document.createElement("tr"); - if (TOTAL_KEYS.has(line.key)) tr.classList.add("is-total"); - if (line.note === L("B09_Estimation_Adopted")) tr.classList.add("is-adopted"); - if (line.note === L("B09_Estimation_NotAdopted")) tr.classList.add("is-dropped"); - - const name = document.createElement("td"); - name.className = "b09-left"; - name.textContent = line.name; - - const amount = document.createElement("td"); - amount.textContent = formatWon(line.amount_krw); - - const rate = document.createElement("td"); - rate.textContent = line.rate_percent === null ? "" : `${line.rate_percent}%`; - - const basis = document.createElement("td"); - basis.className = "b09-left"; - basis.textContent = line.formula_text; - - const note = document.createElement("td"); - note.className = "b09-left"; - note.textContent = line.note; - - mark(name, prov, "name"); - // ⚠ **같은 열 안에서 줄마다 등급이 갈리는 첫 자리.** 중간줄(간접노무비 따위)은 - // `calc` 인데 마지막줄 셋은 계약으로 나가는 `final` 이다. 열 사전은 등급이 하나뿐이라 - // 여기서 칸에 덮어 심는다 — 나머지 칸은 생략해 열 등급을 그대로 물려받는다. - const isFinalLine = - line.key === "total_cost" || line.key === "contract_amount" || line.key === "grand_total"; - const amountColumn = prov?.columns.amount_krw; - // 등급을 빼면 공용 쪽이 열 등급으로 채워 주지만, **여기서 명시**해 두면 그 채움이 - // 없는 판에서도 띠 색이 제대로 붙는다. - if (amountColumn) - markProvenanceCell(amount, "amount_krw", isFinalLine ? "final" : amountColumn.tier); - mark(rate, prov, "rate_percent"); - mark(basis, prov, "formula_text"); - mark(note, prov, "note"); - tr.append(name, amount, rate, basis, note); - tbody.append(tr); - } - table.append(tbody); - wrap.append(table); - attachProvenance(wrap, prov); - return wrap; -} - -/** 일위대가 **목록표** — 「무엇이 있나」. 고르면 아래에 본표가 뜬다(9-3 제목+상세). */ -function buildUnitPriceList( - list: UnitPriceListDto, - selected: string | null, - onPick: (code: string) => void, -): HTMLElement { - const wrap = document.createElement("div"); - wrap.className = "b09-sheet b09-up-list"; - const prov = list.provenance?.sheets?.unit_price_list; - - const caption = document.createElement("div"); - caption.className = "b09-hint"; - caption.textContent = `${L("B09_Estimation_UP_List")} · ${list.summary.unit_prices}`; - wrap.append(caption); - - const table = document.createElement("table"); - const head = document.createElement("tr"); - for (const [key, left] of [ - ["B09_Estimation_Col_Name", true], - ["B09_Estimation_Col_Unit", true], - ["B09_Estimation_Col_Material", false], - ["B09_Estimation_Col_Labor", false], - ["B09_Estimation_Col_Expense", false], - ["B09_Estimation_Col_Total", false], - ] as Array<[keyof typeof ui_locales, boolean]>) { - const th = document.createElement("th"); - th.textContent = L(key); - if (left) th.className = "b09-left"; - head.append(th); - } - const thead = document.createElement("thead"); - thead.append(head); - table.append(thead); - - const body = document.createElement("tbody"); - for (const row of list.rows) { - const tr = document.createElement("tr"); - tr.className = "b09-clickable"; - if (row.code === selected) tr.classList.add("is-adopted"); - tr.addEventListener("click", () => onPick(row.code)); - - const name = document.createElement("td"); - name.className = "b09-left"; - name.textContent = row.name; - const unit = document.createElement("td"); - unit.className = "b09-left"; - unit.textContent = row.unit; - mark(name, prov, "name"); - mark(unit, prov, "unit"); - tr.append(name, unit); - const moneyKeys = ["material", "labor", "expense", "total"]; - [row.material, row.labor, row.expense, row.total].forEach((value, index) => { - const cell = document.createElement("td"); - cell.textContent = formatWon(value); - mark(cell, prov, moneyKeys[index]); - tr.append(cell); - }); - body.append(tr); - } - table.append(body); - wrap.append(table); - attachProvenance(wrap, prov); - return wrap; -} - -/** 일위대가 **본표** — 「무엇으로 이루어졌나」. 줄마다 원천과 파고들기가 붙는다. */ -function buildUnitPriceDetail( - detail: UnitPriceDetailDto, - onDrill: (code: string) => void, -): HTMLElement { - const wrap = document.createElement("div"); - wrap.className = "b09-sheet b09-up-detail"; - const prov = detail.provenance?.sheets?.unit_price_detail; - - const caption = document.createElement("div"); - caption.className = "b09-hint"; - caption.textContent = - `${L("B09_Estimation_UP_Detail")} · ${detail.name}` + - (detail.spec ? ` (${detail.spec})` : "") + - ` · ${formatWon(detail.total)}` + - ` · ${detail.sum_matches ? L("B09_Estimation_UP_SumOk") : L("B09_Estimation_UP_SumBad")}`; - wrap.append(caption); - - // ⚠ **일부만 선 단가는 반드시 말한다.** 안 말하면 조용히 싼 값이 내역서에 그대로 든다 - // (2026-09-09 실측: 일위대가가 선 141 공종 중 70 공종이 이 자리 — 초류종자살포는 - // 자재 다섯·장비 셋이 빠진 채 인력 둘만으로 서 있었다). - if (detail.unattached_note) { - const gap = document.createElement("div"); - gap.className = "b09-hint"; - gap.style.fontWeight = "600"; - gap.textContent = detail.unattached_note; - wrap.append(gap); - } - - // 행별로 0.1원 미만을 버리므로 전정밀 합과 끝자리가 어긋난다 — **정상이다.** - // 숨기면 나중에 「합계가 안 맞는다」며 계산을 고치려 든다. - if (detail.precise_total !== detail.total) { - const gap = document.createElement("div"); - gap.className = "b09-hint"; - gap.textContent = `${L("B09_Estimation_UP_RoundGap")} ${formatWon(detail.precise_total)}`; - wrap.append(gap); - } - - const table = document.createElement("table"); - const head = document.createElement("tr"); - for (const [key, left] of [ - ["B09_Estimation_Col_Name", true], - ["B09_Estimation_Col_Spec", true], - ["B09_Estimation_Col_Source", true], - ["B09_Estimation_Col_Unit", true], - ["B09_Estimation_Col_Qty", false], - ["B09_Estimation_Col_Material", false], - ["B09_Estimation_Col_Labor", false], - ["B09_Estimation_Col_Expense", false], - ["B09_Estimation_Col_Total", false], - ] as Array<[keyof typeof ui_locales, boolean]>) { - const th = document.createElement("th"); - th.textContent = L(key); - if (left) th.className = "b09-left"; - head.append(th); - } - const thead = document.createElement("thead"); - thead.append(head); - table.append(thead); - - const body = document.createElement("tbody"); - for (const row of detail.rows) { - const tr = document.createElement("tr"); - if (row.drillable) { - tr.className = "b09-clickable"; - tr.title = L("B09_Estimation_UP_Drill"); - tr.addEventListener("click", () => onDrill(row.ref_code)); - } - const name = document.createElement("td"); - name.className = "b09-left"; - name.textContent = row.drillable ? `▸ ${row.name}` : row.name; - const spec = document.createElement("td"); - spec.className = "b09-left"; - spec.textContent = row.spec; - const source = document.createElement("td"); - source.className = "b09-left"; - source.textContent = `${row.source_label} (${row.source_index})`; - const unit = document.createElement("td"); - unit.className = "b09-left"; - unit.textContent = row.unit; - mark(name, prov, "name"); - mark(spec, prov, "spec"); - mark(source, prov, "source"); - mark(unit, prov, "unit"); - tr.append(name, spec, source, unit); - const detailKeys = ["quantity", "material", "labor", "expense", "total"]; - [row.quantity, row.material, row.labor, row.expense, row.total].forEach((value, index) => { - const cell = document.createElement("td"); - cell.textContent = formatWon(value); - mark(cell, prov, detailKeys[index]); - tr.append(cell); - }); - body.append(tr); - } - - const sum = document.createElement("tr"); - sum.className = "is-total"; - const label = document.createElement("td"); - label.className = "b09-left"; - label.colSpan = 5; - label.textContent = L("B09_Estimation_Col_Total"); - sum.append(label); - for (const value of [detail.material, detail.labor, detail.expense, detail.total]) { - const cell = document.createElement("td"); - cell.textContent = formatWon(value); - sum.append(cell); - } - body.append(sum); - - table.append(body); - wrap.append(table); - attachProvenance(wrap, prov); - return wrap; -} - -/* ----------------------------------------------------------------------------- - * 좌측 패널 - * -------------------------------------------------------------------------- */ - -interface PanelHandles { - root: HTMLElement; - rateVersionBox: HTMLElement; - hintBox: HTMLElement; -} - -function buildSidePanel( - form: CostFormState, - onRecalc: () => void, - onConfirm: () => void, -): PanelHandles { - const root = document.createElement("div"); - root.className = "b09-panel"; - - const addGroup = ( - legendKey: keyof typeof ui_locales, - fields: Array<[keyof CostFormState, keyof typeof ui_locales]>, - ): void => { - const group = document.createElement("section"); - group.className = "b09-panel__group ui-collapsible ui-sidebar-section"; - const legend = document.createElement("span"); - legend.className = "b09-panel__legend ui-collapsible__title"; - legend.textContent = L(legendKey); - group.append(legend); - for (const [field, labelKey] of fields) { - const handle = createInputField({ - label: L(labelKey), - type: "number", - min: 0, - value: form[field], - onInput: (value) => { - form[field] = value; - }, - }); - group.append(handle.root); - } - root.append(group); - }; - - addGroup("B09_Estimation_Group_Condition", [ - ["direct_material_krw", "B09_Estimation_Field_DirectMaterial"], - ["direct_labor_krw", "B09_Estimation_Field_DirectLabor"], - ["direct_expense_krw", "B09_Estimation_Field_DirectExpense"], - ["duration_days", "B09_Estimation_Field_Duration"], - ]); - - // 요율 판 — 읽기 전용. 「어느 판으로 계산했나」가 화면에 남아야 재현성이 선다. - const rateGroup = document.createElement("section"); - rateGroup.className = "b09-panel__group ui-collapsible ui-sidebar-section"; - const rateLegend = document.createElement("span"); - rateLegend.className = "b09-panel__legend ui-collapsible__title"; - rateLegend.textContent = L("B09_Estimation_Group_RateVersion"); - const rateVersionBox = document.createElement("div"); - rateGroup.append(rateLegend, rateVersionBox); - root.append(rateGroup); - - addGroup("B09_Estimation_Group_Supplied", [ - ["owner_supplied_material_krw", "B09_Estimation_Field_OwnerMaterial"], - ["procurement_fee_krw", "B09_Estimation_Field_ProcurementFee"], - ]); - - addGroup("B09_Estimation_Group_Profit", [ - ["profit_adjustment_krw", "B09_Estimation_Field_ProfitAdjust"], - ["target_contract_amount_krw", "B09_Estimation_Field_TargetContract"], - ]); - - // 수량 — 여러 줄이라 텍스트 영역으로. 비어 있으면 위 직접비 3칸을 그대로 쓴다. - const quantityGroup = document.createElement("section"); - quantityGroup.className = "b09-panel__group ui-collapsible ui-sidebar-section"; - const quantityLegend = document.createElement("span"); - quantityLegend.className = "b09-panel__legend ui-collapsible__title"; - quantityLegend.textContent = L("B09_Estimation_Group_Quantity"); - const quantityLabel = document.createElement("label"); - quantityLabel.className = "ui-field__label"; - quantityLabel.textContent = L("B09_Estimation_Field_Quantities"); - const quantityInput = document.createElement("textarea"); - quantityInput.className = "ui-input b09-qty"; - quantityInput.rows = 4; - quantityInput.placeholder = "FP-09-21=500"; - quantityInput.addEventListener("input", () => { - form.quantities_text = quantityInput.value; - }); - quantityGroup.append(quantityLegend, quantityLabel, quantityInput); - root.append(quantityGroup); - - const hintBox = document.createElement("div"); - hintBox.className = "b09-hint"; - root.append(hintBox); - - const actions = document.createElement("div"); - // 바닥 고정 액션 줄(공용) — ui_template_overlay 가 이 줄을 스크롤 밖으로 빼낸다. - actions.className = "ui-sidebar-actions"; - actions.append( - createButton({ - label: L("B09_Estimation_Btn_Recalc"), - variant: "filled", - onClick: onRecalc, - }), - createButton({ - label: L("B09_Estimation_Btn_Confirm"), - onClick: onConfirm, - }), - ); - root.append(actions); - - // 그룹 제목 행 클릭 시 접기/펼치기(B04~B07 공통). 액션 줄은 collapsible 이 아니다. - attachCollapsible(root); - - return { root, rateVersionBox, hintBox }; -} - -function renderRateVersion(box: HTMLElement, sheet: CostSheetDto | null): void { - box.replaceChildren(); - if (!sheet) return; - const rows: Array<[string, string]> = [ - ["적용일", sheet.rate_version.effective_date || "—"], - ["지문", sheet.rate_version.sha256 ? `${sheet.rate_version.sha256.slice(0, 8)}…` : "—"], - ]; - for (const [label, value] of rows) { - const row = document.createElement("div"); - row.className = "b09-panel__readonly"; - const left = document.createElement("span"); - left.textContent = label; - const right = document.createElement("span"); - right.textContent = value; - row.append(left, right); - box.append(row); - } -} - -/* ----------------------------------------------------------------------------- - * 탭 - * -------------------------------------------------------------------------- */ - -const TAB_KEYS: Array<[string, keyof typeof ui_locales, boolean]> = [ - ["cost_sheet", "B09_Estimation_Tab_CostSheet", true], - ["boq", "B09_Estimation_Tab_Boq", true], - ["unit_price", "B09_Estimation_Tab_UnitPrice", true], - ["price_basis", "B09_Estimation_Tab_PriceBasis", true], - ["machine", "B09_Estimation_Tab_Machine", true], - ["duration", "B09_Estimation_Tab_Duration", false], - ["supply", "B09_Estimation_Tab_Supply", true], - ["base_data", "B09_Estimation_Tab_BaseData", true], - ["design_doc", "B09_Estimation_Tab_DesignDoc", true], - ["basis_sheet", "B09_Estimation_Tab_BasisSheet", true], -]; - -function buildTabs(active: string, onSelect: (key: string) => void): HTMLElement { - const bar = document.createElement("div"); - bar.className = "b09-tabs"; - for (const [key, labelKey, enabled] of TAB_KEYS) { - const button = document.createElement("button"); - button.type = "button"; - button.className = "b09-tab"; - button.dataset.tab = key; - button.textContent = L(labelKey); - button.disabled = !enabled; - if (!enabled) button.title = L("B09_Estimation_Tab_Pending"); - if (key === active) button.classList.add("is-active"); - button.addEventListener("click", () => onSelect(key)); - bar.append(button); - } - return bar; -} - -/* ----------------------------------------------------------------------------- - * API - * -------------------------------------------------------------------------- */ - -/** 「공종코드=수량」 여러 줄을 객체로. 형식이 아닌 줄은 조용히 버리지 않고 건너뛴다. */ -function parseQuantities(text: string): Record { - const out: Record = {}; - for (const line of text.split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed) continue; - const [code, value] = trimmed.split(/[=\t,]/); - if (!code || !value) continue; - const qty = value.trim(); - if (!/^\d+(\.\d+)?$/.test(qty)) continue; - out[code.trim()] = qty; - } - return out; -} - -function toRequestBody(form: CostFormState): Record { - const num = (value: string): string => (value.trim() === "" ? "0" : value.trim()); - const body: Record = { - direct_material_krw: num(form.direct_material_krw), - direct_labor_krw: num(form.direct_labor_krw), - direct_expense_krw: num(form.direct_expense_krw), - duration_days: Number(num(form.duration_days)), - owner_supplied_material_krw: num(form.owner_supplied_material_krw), - procurement_fee_krw: num(form.procurement_fee_krw), - profit_adjustment_krw: num(form.profit_adjustment_krw), - }; - if (form.target_contract_amount_krw.trim() !== "") { - body.target_contract_amount_krw = form.target_contract_amount_krw.trim(); - } - const quantities = parseQuantities(form.quantities_text); - if (Object.keys(quantities).length > 0) body.quantities = quantities; - return body; -} - -async function fetchCostSheet(projectId: string, form: CostFormState): Promise { - const response = await fetch( - `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/cost`, - { - method: "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(toRequestBody(form)), - }, - ); - if (!response.ok) throw new Error(`estimation cost failed: ${response.status}`); - return (await response.json()) as CostSheetDto; -} - -async function fetchUnitPriceList(projectId: string): Promise { - const response = await fetch( - `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/unit-prices`, - { credentials: "include" }, - ); - if (!response.ok) throw new Error(`unit price list failed: ${response.status}`); - return (await response.json()) as UnitPriceListDto; -} - -async function fetchUnitPriceDetail(projectId: string, code: string): Promise { - const response = await fetch( - `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/unit-prices/${encodeURIComponent(code)}`, - { credentials: "include" }, - ); - if (!response.ok) throw new Error(`unit price detail failed: ${response.status}`); - return (await response.json()) as UnitPriceDetailDto; -} - -/** ④ 예산내역서 한 줄. 금액이 `null` 이면 **못 세운 것**이지 0 이 아니다. */ -interface BillRowDto { - item_no: string; - level: number; - code: string | null; - name: string; - spec: string; - unit: string; - quantity: string | null; - /** 품셈 1-2-2 종목별 자리로 반올림한 표시값. 자리를 모르면 `null`. */ - quantity_shown: string | null; - quantity_digits: number | null; - unit_price_krw: string | null; - amount_krw: string | null; - is_group: boolean; - in_bill: boolean; - note: string; - /** - * 줄 사유 **조각** — 어느 사유가 어느 열에 닿는지까지 서버가 갈라 보낸다. - * ⚠ 줄에 달렸다고 모든 칸에 띄우면 「금액」 카드에 「갈래 근거…」 가 떠서 읽는 사람을 - * 속인다(2026-09-12 B08 실측). `column` 이 빈 글인 것만 줄 전체에 붙는다. - */ - notes?: Array<{ column: string; text: string }>; -} - -interface PriceBasisEntryDto { - number: number; - label: string; - code: string; - name: string; - spec: string; - unit: string; - unit_price_krw: string; - ref_code: string; -} - -interface BillDto { - rows: BillRowDto[]; - excluded: BillRowDto[]; - materials: BillRowDto[]; - summary: { - rows: number; - detail_rows: number; - body_total_krw: string; - missing: Array<{ - name: string; - reason: string; - unit?: string; - quantity?: string; - blocked_kind?: string; - }>; - notes: string[]; - material_sheet: MaterialSheetDto | null; - }; - price_basis: { entries: PriceBasisEntryDto[] }; - provenance?: ProvenancePayload; -} - -interface MaterialSheetRowDto { - name: string; - spec: string; - unit: string; - total_amount: string; - unit_price_krw: string | null; - amount_krw: string | null; - note: string; - notes?: Array<{ column: string; text: string }>; -} - -interface MaterialSheetDto { - contractor: MaterialSheetRowDto[]; - owner: MaterialSheetRowDto[]; - unknown: MaterialSheetRowDto[]; - contractor_total_krw: string; - owner_total_krw: string; - missing: Array<{ name: string; reason: string }>; - notes: string[]; -} - -/** - * 수량 표시 — **종목마다 자리가 다르다** (산림품셈 1-2-2의 1). - * - * 체적합계·시멘트·철근은 정수, 돌쌓기·옹벽·떼는 1자리, 철강재는 3자리다. 서버가 줄마다 - * `quantity_digits` 를 실어 보내므로 여기서는 그 자리로 찍기만 한다. - * ⚠ 자리를 못 찾은 줄은 `null` 로 오고, 그때만 **종전 2자리**로 찍는다 — 모르는 것을 - * 아는 척 자르지 않는다. - * - * ⚠ 표시값끼리 곱하면 금액이 몇 원 어긋난다(90.51 × 5,288.6 ≠ 화면 금액). 그것이 - * 정상임을 표 아래 문구로 밝힌다 — 밝히지 않으면 「1원 틀린다」는 지적으로 돌아온다. - */ -function formatQuantity(value: string | null, digits: number | null = null): string { - if (value === null || value === "") return ""; - const parsed = Number(value); - if (!Number.isFinite(parsed)) return value; - const places = digits ?? 2; - return parsed.toLocaleString("ko-KR", { - minimumFractionDigits: places, - maximumFractionDigits: places, - }); -} - -async function fetchBill(projectId: string): Promise { - const response = await fetch( - `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/bill`, - { credentials: "include" }, - ); - if (!response.ok) throw new Error(`estimation bill failed: ${response.status}`); - return (await response.json()) as BillDto; -} - -async function confirmEstimationStage(projectId: string): Promise { - const response = await fetch( - `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/confirm`, - { method: "POST", credentials: "include" }, - ); - if (!response.ok) throw new Error(`estimation confirm failed: ${response.status}`); -} - -/* ----------------------------------------------------------------------------- - * 페이지 진입점 - * -------------------------------------------------------------------------- */ - -export async function renderB09Estimation(root: HTMLElement): Promise { - injectStyles(); - const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); - const form: CostFormState = { ...INITIAL_FORM }; - let activeTab = "cost_sheet"; - let baseData: BaseDataDto | null = null; - let priceSources: PriceSourcesDto | null = null; - let factorChoices: FactorChoicesDto | null = null; - let designDoc: DesignDocDto | null = null; - let basisSheet: BasisSheetDto | null = null; - let machineExpense: MachineExpenseDto | null = null; - let sheet: CostSheetDto | null = null; - let unitPriceList: UnitPriceListDto | null = null; - let unitPriceDetail: UnitPriceDetailDto | null = null; - let selectedUnitPrice: string | null = null; - let bill: BillDto | null = null; - let priceBasis: string | null = null; - // 근거 사전이 **한 번이라도** 왔는지 — 개발환경에서만 온다. 안 오면 토글도 안 세운다 - // (없는 기능의 단추가 떠 있으면 눌러 보고 「고장났다」고 읽는다). - let hasProvenance = false; - - const main = document.createElement("div"); - main.className = "b09-main"; - const body = document.createElement("div"); - body.style.flex = "1"; - body.style.minHeight = "0"; - // ⚠ 세로와 마찬가지로 **가로도 0 으로 끊어야** 한다(2026-09-08 실측). 안 끊으면 이 칸이 - // 안쪽 표의 최소폭(칸이 nowrap)을 그대로 물어 창 620 에서 1,190px 밀려 나갔다. - // 0 이면 아래 .b09-sheet 의 overflow:auto 가 살아나 **표만 제 안에서 가로로 넘어간다.** - body.style.minWidth = "0"; - body.style.display = "flex"; - body.style.flexDirection = "column"; - - /** 사전이 **처음 온 순간에만** 탭 줄을 다시 세운다 — 토글이 그때 생긴다. */ - const noteProvenance = (payload?: ProvenancePayload): void => { - if (!payload || hasProvenance) return; - hasProvenance = true; - drawTabs(); - }; - - /** 일위대가 본표를 불러 다시 그린다 — 기계 줄을 누르면 그 층으로 파고든다. */ - const openUnitPrice = async (code: string): Promise => { - if (!projectId) return; - try { - unitPriceDetail = await fetchUnitPriceDetail(projectId, code); - noteProvenance(unitPriceDetail.provenance); - selectedUnitPrice = code; - drawBody(); - } catch { - showToast(L("B09_Estimation_UP_Load_Failed"), "error"); - } - }; - - const drawUnitPriceTab = (): void => { - if (!unitPriceList) { - const empty = document.createElement("div"); - empty.className = "b09-empty"; - empty.textContent = L("B09_Estimation_Tab_Pending"); - body.append(empty); - return; - } - // 산출 요약을 **화면에도** 낸다 — 무엇이 안 선 상태인지 사용자가 알아야 한다. - for (const note of unitPriceList.summary.notes) { - const line = document.createElement("div"); - line.className = "b09-hint"; - line.textContent = note; - body.append(line); - } - // ⚠ 표본이 얇은 노임(`*` 조사현장 5개 미만 · `**` 미조사)이 내역에 실렸으면 알린다. - // 금액을 막지 않는다 — 값은 그대로 서고 **무엇이 얇은지**만 말한다 - // (지식DB `노임단가_적용 §2-3` 「단가 채택 시 플래그 유지 필요」). - for (const item of unitPriceList.summary.labor_reliability ?? []) { - const line = document.createElement("div"); - line.className = "b09-hint b09-hint--warn"; - line.textContent = `⚠ ${item.name}(${item.code}) ${item.flag} — ${item.why}`; - body.append(line); - } - body.append( - buildUnitPriceList(unitPriceList, selectedUnitPrice, (code) => { - void openUnitPrice(code); - }), - ); - if (unitPriceDetail) { - body.append( - buildUnitPriceDetail(unitPriceDetail, (code) => { - void openUnitPrice(code); - }), - ); - } else { - const hint = document.createElement("div"); - hint.className = "b09-empty"; - hint.textContent = L("B09_Estimation_UP_Pick"); - body.append(hint); - } - }; - - /** ④ 예산내역서 — B08 수량에 단가를 붙인 표. 못 세운 줄은 **그대로 보인다**. */ - const drawBoqTab = (): void => { - if (!bill) { - if (!projectId) { - const empty = document.createElement("div"); - empty.className = "b09-empty"; - empty.textContent = L("B09_Estimation_Boq_Failed"); - body.append(empty); - return; - } - const load = document.createElement("button"); - load.type = "button"; - load.className = "b09-btn"; - load.textContent = L("B09_Estimation_Boq_Load"); - load.addEventListener("click", () => { - void (async () => { - try { - bill = await fetchBill(projectId); - noteProvenance(bill.provenance); - } catch { - bill = null; - window.alert(L("B09_Estimation_Boq_Failed")); - } - drawBody(); - })(); - }); - body.append(load); - return; - } - - const table = document.createElement("table"); - table.className = "b09-sheet"; - const head = document.createElement("thead"); - head.innerHTML = - "No.공종규격단위" + - "수량단가금액비고"; - // 열 키는 서버 `BillRow.as_dict()` 낱말과 같아야 사전이 붙는다. - const boqKeys = [ - "item_no", - "name", - "spec", - "unit", - "quantity", - "unit_price_krw", - "amount_krw", - "note", - ]; - const prov = bill.provenance?.sheets?.boq; - const tbody = document.createElement("tbody"); - for (const row of bill.rows) { - const tr = document.createElement("tr"); - // 계층은 들여쓰기로 보인다 — 번호만으로는 깊이가 안 읽힌다. - const indent = " ".repeat(Math.max(0, (row.level - 1) * 2)); - const cells = row.is_group - ? [row.item_no, indent + row.name, "", "", "", "", "", ""] - : [ - row.item_no, - indent + row.name, - row.spec, - row.unit, - formatQuantity(row.quantity_shown ?? row.quantity, row.quantity_digits), - row.unit_price_krw ?? "", - row.amount_krw ?? "", - row.note, - ]; - cells.forEach((text, index) => { - const td = document.createElement("td"); - td.textContent = text; - // 머리(그룹)줄은 값이 없다 — 빈 칸에 카드를 띄우면 「설명이 있다」는 거짓이 남는다. - if (!row.is_group) mark(td, prov, boqKeys[index]); - tr.append(td); - }); - if (row.is_group) tr.style.fontWeight = "600"; - else stashRowNotes(tr, row.notes); - tbody.append(tr); - } - table.append(head, tbody); - body.append(table); - attachProvenance(table, prov, rowNotesFor); - - const total = document.createElement("div"); - total.className = "b09-hint"; - total.textContent = `${L("B09_Estimation_Boq_Total")}: ${bill.summary.body_total_krw}`; - body.append(total); - - // 표시 자릿수와 계산 자릿수가 다르다는 것을 숨기지 않는다. - const precision = document.createElement("div"); - precision.className = "b09-hint"; - precision.textContent = L("B09_Estimation_Boq_Precision"); - body.append(precision); - - // ⚠ 자재비가 빠진 채 선 합계임을 숨기지 않는다. - const shortfall = document.createElement("div"); - shortfall.className = "b09-hint"; - shortfall.textContent = L("B09_Estimation_Boq_NoMaterialPrice"); - body.append(shortfall); - - if (bill.excluded.length > 0) { - const note = document.createElement("div"); - note.className = "b09-hint"; - note.textContent = - `${L("B09_Estimation_Boq_Excluded")}: ` + - bill.excluded - .map( - (row) => - `${row.name} ${formatQuantity(row.quantity_shown ?? row.quantity, row.quantity_digits)}${row.unit}`, - ) - .join(", "); - body.append(note); - } - - if (bill.summary.missing.length > 0) { - // ⚠ **머리글을 한 번만 단다.** 종전엔 「못 세운 줄 (16)」 뒤에 갈래별 머리글이 - // 또 붙어 **같은 수가 두 번** 떴다. 안내를 더하는 것이 곧 다른 안내를 묻는 것이라 - // (2026-09-08 메인 창 지적), 한 화면에 뜨는 줄 수를 늘리지 않는다. - // ⚠ **할 일이 다르므로 갈라 보인다** — 「사용자가 입력하면 풀리는 것」과 - // 「우리가 만들어야 하는 것」. 한 목록에 섞으면 사용자가 무엇을 해야 할지 못 읽는다. - // ⚠ **세 갈래로 가른다.** 「여기서 세지 않는 줄」을 할 일 목록에 얹으면 - // 사용자가 세우려 들고, 그것이 곧 이중계상이다(㉠~㉦ 규칙). - const notOurs = bill.summary.missing.filter((item) => item.blocked_kind === "not_our_row"); - const needsInput = bill.summary.missing.filter( - (item) => item.blocked_kind === "input_missing", - ); - const rest = bill.summary.missing.filter( - (item) => item.blocked_kind !== "input_missing" && item.blocked_kind !== "not_our_row", - ); - for (const [labelKey, group] of [ - ["B09_Estimation_Boq_NeedsInput", needsInput], - ["B09_Estimation_Boq_NeedsWork", rest], - ["B09_Estimation_Boq_NotOurs", notOurs], - ] as Array<[keyof typeof ui_locales, typeof bill.summary.missing]>) { - if (group.length === 0) continue; - const head = document.createElement("div"); - head.className = "b09-hint"; - // 갈래가 하나뿐이면 「금액을 못 세운 줄」이라는 말을 앞에 붙여 뜻이 온전하게 한다. - const prefix = - needsInput.length > 0 && rest.length > 0 ? "" : `${L("B09_Estimation_Boq_Missing")} — `; - head.textContent = `${prefix}${L(labelKey)} (${group.length})`; - body.append(head); - const list = document.createElement("ul"); - for (const item of group) { - const li = document.createElement("li"); - li.textContent = `${item.name} — ${item.reason}`; - list.append(li); - } - body.append(list); - } - } - - if (bill.materials.length > 0) { - const note = document.createElement("div"); - note.className = "b09-hint"; - note.textContent = - `${L("B09_Estimation_Boq_Materials")}: ` + - bill.materials.map((row) => `${row.name} ${row.quantity ?? ""}${row.unit}`).join(", "); - body.append(note); - } - }; - - /** ③ 단가산출서 — 내역 줄의 단가가 **어떻게 나왔는지** 보이는 표(실무 「단산 46 참조」). */ - const drawPriceBasisTab = (): void => { - if (!bill) { - const empty = document.createElement("div"); - empty.className = "b09-empty"; - empty.textContent = L("B09_Estimation_PB_Empty"); - body.append(empty); - return; - } - const entries = bill.price_basis?.entries ?? []; - // 일위대가 탭과 **같은 모양**으로 — 목록 위, 본표 아래 2단(PLAN 9-3 「표를 세 벌 - // 만들지 않는다」와 같은 뜻: 화면도 한 벌로 쓴다). - const split = document.createElement("div"); - - const list = document.createElement("table"); - list.className = "b09-sheet b09-up-list"; - list.innerHTML = "번호공종단위단가"; - // 모으기만 하는 표라 사전에 **식이 없다** — 「어느 표에서 왔나」만 카드에 뜬다. - const prov = bill.provenance?.sheets?.price_basis; - const pbKeys = ["number", "name", "unit", "unit_price_krw"]; - const tbody = document.createElement("tbody"); - for (const entry of entries) { - const tr = document.createElement("tr"); - [ - String(entry.number), - `${entry.name} ${entry.spec}`.trim(), - entry.unit, - entry.unit_price_krw, - ].forEach((text, index) => { - const td = document.createElement("td"); - td.textContent = text; - mark(td, prov, pbKeys[index]); - tr.append(td); - }); - tr.style.cursor = "pointer"; - if (entry.code === priceBasis) tr.style.fontWeight = "600"; - tr.addEventListener("click", () => { - priceBasis = entry.code; - drawBody(); - }); - tbody.append(tr); - } - list.append(tbody); - attachProvenance(list, prov); - split.append(list); - - const picked = entries.find((entry) => entry.code === priceBasis) ?? null; - const panel = document.createElement("div"); - panel.className = "b09-up-detail"; - if (picked === null) { - panel.textContent = L("B09_Estimation_PB_Pick"); - } else { - const head = document.createElement("div"); - head.className = "b09-hint"; - head.textContent = `${picked.label} — ${picked.name} ${picked.spec} (${picked.unit}) ${picked.unit_price_krw}`; - const ref = document.createElement("div"); - ref.className = "b09-hint"; - // 한 층 아래(일위대가)를 가리킨다 — 그 표는 일위대가 탭에서 그대로 본다. - ref.textContent = `${L("B09_Estimation_PB_Ref")}: ${picked.ref_code}`; - panel.append(head, ref); - } - split.append(panel); - body.append(split); - }; - - /** 자재대 — B08 수량·할증에 단가를 붙인 표. 관급은 **총원가 밖 별도 표기**다. */ - const drawMaterialTab = (): void => { - const sheet = bill?.summary.material_sheet ?? null; - if (!sheet) { - const empty = document.createElement("div"); - empty.className = "b09-empty"; - empty.textContent = L("B09_Estimation_Mat_Empty"); - body.append(empty); - return; - } - - // 자재가 아예 없으면 **빈 표 셋을 늘어놓지 않는다** — 「없다」 한 줄이면 된다 - // (2026-09-08 화면 전수에서 세 무리가 모두 「(0) — 0」 으로 뜨고 있었다). - if (sheet.contractor.length === 0 && sheet.owner.length === 0 && sheet.unknown.length === 0) { - const none = document.createElement("div"); - none.className = "b09-empty"; - none.textContent = L("B09_Estimation_Mat_None"); - body.append(none); - return; - } - - // ⚠ 「안 갈린 것」은 **못 세운 것이 아니라 세면 안 되는 것**이라 사전을 따로 쓴다 - // (`excluded` — 채우면 이중계상, PLAN 8-36 ㉱). - for (const [labelKey, rows, total, sheetName] of [ - ["B09_Estimation_Mat_Contractor", sheet.contractor, sheet.contractor_total_krw, "material"], - ["B09_Estimation_Mat_Owner", sheet.owner, sheet.owner_total_krw, "material"], - ["B09_Estimation_Mat_Unknown", sheet.unknown, null, "material_unknown"], - ] as Array<[keyof typeof ui_locales, MaterialSheetRowDto[], string | null, string]>) { - const head = document.createElement("div"); - head.className = "b09-hint"; - head.textContent = `${L(labelKey)} (${rows.length})` + (total === null ? "" : ` — ${total}`); - body.append(head); - if (rows.length === 0) continue; - - const table = document.createElement("table"); - table.className = "b09-sheet"; - table.innerHTML = - "자재규격단위수량" + - "단가금액비고"; - const matKeys = [ - "name", - "spec", - "unit", - "total_amount", - "unit_price_krw", - "amount_krw", - "note", - ]; - const prov = bill?.provenance?.sheets?.[sheetName]; - const tbody = document.createElement("tbody"); - for (const row of rows) { - const tr = document.createElement("tr"); - [ - row.name, - row.spec, - row.unit, - formatQuantity(row.total_amount), - row.unit_price_krw ?? "", - row.amount_krw ?? "", - row.note, - ].forEach((text, index) => { - const td = document.createElement("td"); - td.textContent = text; - mark(td, prov, matKeys[index]); - tr.append(td); - }); - stashRowNotes(tr, row.notes); - tbody.append(tr); - } - table.append(tbody); - body.append(table); - attachProvenance(table, prov, rowNotesFor); - } - - for (const note of sheet.notes) { - const line = document.createElement("div"); - line.className = "b09-hint"; - line.textContent = note.replace(/\*\*/g, ""); - body.append(line); - } - }; - - const drawBody = (): void => { - body.replaceChildren(); - if (activeTab === "unit_price") { - drawUnitPriceTab(); - return; - } - if (activeTab === "boq") { - drawBoqTab(); - return; - } - if (activeTab === "price_basis") { - drawPriceBasisTab(); - return; - } - if (activeTab === "supply") { - drawMaterialTab(); - return; - } - if (activeTab === "basis_sheet") { - // 산출기초 — 줄에 달린 근거를 모아 오는 장이라 조립이 끝나야 뜬다. - if (basisSheet) { - drawBasisSheet(body, basisSheet); - return; - } - const waiting = document.createElement("div"); - waiting.className = "b09-empty"; - waiting.textContent = L("B09_Estimation_Tab_Pending"); - body.append(waiting); - if (projectId) { - void fetchBasisSheet(projectId) - .then((data) => { - basisSheet = data; - noteProvenance(data.provenance); - drawBody(); - }) - .catch(() => { - /* 못 받아도 화면을 비우지 않는다. */ - }); - } - return; - } - if (activeTab === "design_doc") { - // 설계서 구성표 — 프로젝트 값이 아니라 **우리가 무엇을 내는가**의 표다. - if (designDoc) { - drawDesignDocTab(body, designDoc); - return; - } - const loading = document.createElement("div"); - loading.className = "b09-empty"; - loading.textContent = L("B09_Estimation_Tab_Pending"); - body.append(loading); - if (projectId) { - void fetchDesignDocIndex(projectId) - .then((data) => { - designDoc = data; - drawBody(); - }) - .catch(() => { - /* 못 받아도 화면을 비우지 않는다 — 위 문구가 그대로 남는다. */ - }); - } - return; - } - if (activeTab === "base_data" || activeTab === "machine") { - // 기초자료 네 표 — 없으면 한 번 받아 오고, 받은 뒤 다시 그린다. - if (!baseData) { - const loading = document.createElement("div"); - loading.className = "b09-empty"; - loading.textContent = L("B09_Estimation_Tab_Pending"); - body.append(loading); - if (projectId) { - void fetchBaseData(projectId) - .then((data) => { - baseData = data; - noteProvenance(data.provenance); - drawBody(); - }) - .catch(() => { - /* 못 받아도 화면을 비우지 않는다 — 위 문구가 그대로 남는다. */ - }); - } - return; - } - if (activeTab === "machine") { - drawMachineTab(body, baseData); - // 계산서는 목록표 **아래**에 붙는다 — 「얼마」를 보고 「왜」로 내려간다. - if (machineExpense) { - drawMachineExpense(body, machineExpense); - return; - } - if (projectId) { - void fetchMachineExpense(projectId) - .then((data) => { - machineExpense = data; - drawBody(); - }) - .catch(() => { - /* 못 받아도 목록표는 그대로 선다. */ - }); - } - return; - } - // 산출 조건이 목록표보다 **먼저** 선다 — 값을 낳는 자리가 값보다 아래 있으면 - // 사용자가 「바꿀 수 있는 것」을 못 본다. - if (factorChoices && projectId) { - drawFactorChoices(body, factorChoices, projectId, () => { - factorChoices = null; - baseData = null; - priceSources = null; - drawBody(); - }); - } else if (projectId) { - void fetchFactorChoices(projectId) - .then((data) => { - factorChoices = data; - drawBody(); - }) - .catch(() => { - /* 못 받아도 아래 표는 그대로 선다. */ - }); - } - drawBaseDataTab(body, baseData); - // 자재단가대비표·환율및기초자료는 **따로 받아 온다** — 목록표 넷이 먼저 서고 - // 두 표가 뒤따라 붙는다. 안 붙으면 위 넷도 못 보게 되는 것을 막는다. - if (priceSources && projectId) { - drawPriceSourcesSections(body, priceSources, projectId, () => { - // 유가 지역을 바꾸면 **기계 연료비가 다시 서므로** 목록표까지 함께 새로 받는다. - factorChoices = null; - baseData = null; - priceSources = null; - drawBody(); - }); - return; - } - drawPriceSourcesPending(body); - if (projectId) { - void fetchPriceSources(projectId) - .then((data) => { - priceSources = data; - noteProvenance(data.provenance); - drawBody(); - }) - .catch(() => { - /* 못 받아도 목록표 넷은 그대로 남는다. */ - }); - } - return; - } - if (activeTab !== "cost_sheet") { - const empty = document.createElement("div"); - empty.className = "b09-empty"; - empty.textContent = L("B09_Estimation_Tab_Pending"); - body.append(empty); - return; - } - if (!sheet) { - const empty = document.createElement("div"); - empty.className = "b09-empty"; - empty.textContent = L("B09_Estimation_Btn_Recalc"); - body.append(empty); - return; - } - // 어느 값으로 계산했는지 화면에 남긴다 — 안 보이면 나중에 못 가른다. - const source = document.createElement("div"); - source.className = "b09-hint"; - source.textContent = - sheet.direct_cost_source === "quantities" - ? L("B09_Estimation_Src_Quantities") - : L("B09_Estimation_Src_Manual"); - body.append(source); - - // 수량은 있는데 단가가 없는 공종 — 총액에서 빠졌으므로 **반드시 보인다**. - if (sheet.missing_unit_prices.length > 0) { - const missing = document.createElement("div"); - missing.className = "b09-hint"; - missing.textContent = `${L("B09_Estimation_Missing_UP")} ${sheet.missing_unit_prices.join(", ")}`; - body.append(missing); - } - - body.append(buildCostSheetTable(sheet)); - for (const note of sheet.notes) { - const line = document.createElement("div"); - line.className = "b09-hint"; - line.textContent = note; - body.append(line); - } - }; - - const drawTabs = (): void => { - const bar = buildTabs(activeTab, (key) => { - activeTab = key; - drawTabs(); - drawBody(); - if (key === "unit_price" && !unitPriceList && projectId) { - void fetchUnitPriceList(projectId) - .then((data) => { - unitPriceList = data; - noteProvenance(data.provenance); - drawBody(); - }) - .catch(() => showToast(L("B09_Estimation_UP_Load_Failed"), "error")); - } - }); - // ⚠ 등급색은 **평소엔 꺼 둔다** — 여덟 색이 늘 켜져 있으면 표가 알록달록해 - // 실무 시트와 눈으로 대조를 못 한다(PLAN 8-36 ②). 단추는 탭 줄 끝에 둔다. - if (hasProvenance) bar.append(createProvenanceToggle(root)); - const old = main.querySelector(".b09-tabs"); - if (old) old.replaceWith(bar); - else main.prepend(bar); - }; - - const panel = buildSidePanel( - form, - async () => { - if (!projectId) return; - try { - sheet = await fetchCostSheet(projectId, form); - noteProvenance(sheet.provenance); - renderRateVersion(panel.rateVersionBox, sheet); - panel.hintBox.textContent = - sheet.suggested_profit_adjustment_krw && sheet.suggested_profit_adjustment_krw !== "0" - ? `${L("B09_Estimation_Suggest_Adjust")} ${formatWon(sheet.suggested_profit_adjustment_krw)}` - : ""; - drawBody(); - } catch { - showToast(L("B09_Estimation_Calc_Failed"), "error"); - } - }, - async () => { - if (!projectId) return; - try { - await confirmEstimationStage(projectId); - showToast(L("B09_Estimation_Confirm_Success"), "success"); - goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[6]); - } catch { - showToast(L("B09_Estimation_Confirm_Failed"), "error"); - } - }, - ); - - main.append(body); - drawTabs(); - drawBody(); - - const layout = createWorkflowLayout({ - title: L("B09_Estimation_Title"), - steps: workflowSteps(), - activeStep: 6, - leftPanel: panel.root, - mainContent: main, - routes: WORKFLOW_STEP_ROUTES, - onStepClick: (stepIndex) => { - if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]); - }, - }); - root.append(layout.root); -} diff --git a/B09_Estimation/B09_Estimation_UI_Sheet.ts b/B09_Estimation/B09_Estimation_UI_Sheet.ts new file mode 100644 index 00000000..f85a1441 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Sheet.ts @@ -0,0 +1,227 @@ +/* ============================================================================= + * B09_Estimation_UI_Sheet.ts + * B09 실무 서식 표 공용 — 내역서·일위대가표·단가산출근거가 같은 모양으로 섬 (PLAN 12장) + * + * - 표 모양은 STmate 출력 시트 그대로: 명칭·규격·수량·단위 · 합계/노무비/재료비/경비 (단가·금액) · 비고. + * - ⚠ 여기서 곱하거나 더하지 않음 — 서버가 실은 칸을 찍기만. 숫자 꼴(천 단위 쉼표)만 바꿈. + * - 가로 넘침은 표 칸 안에서만(`min-width:0` · `overflow-x:auto`) — 옛 화면 2026-09-08 실측 교훈. + * ========================================================================== */ + +import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; + +export function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +/** 금액 글 → 「1,234,567」 · 소수부가 있으면 한 자리(일위대가 금액란 0.1원). 숫자가 아니면 그대로. */ +export function won(value: string | null | undefined): string { + if (value === null || value === undefined || value === "") return ""; + const number = Number(value); + if (!Number.isFinite(number)) return String(value); + const fraction = Math.abs(number % 1) > 1e-9; + return number.toLocaleString("ko-KR", { + minimumFractionDigits: fraction ? 1 : 0, + maximumFractionDigits: fraction ? 1 : 0, + }); +} + +/** 수량 — 서버가 준 표시 자리(`digits`). 내역 줄인데 자리를 모르면 옛 화면처럼 둘째 자리, + * 호표 안 줄(`digits` 안 줌)은 품이 작아(0.0115 hr) 넷째 자리까지. */ +export function quantity(value: string | null | undefined, digits?: number | null): string { + if (value === null || value === undefined || value === "") return ""; + const number = Number(value); + if (!Number.isFinite(number)) return String(value); + if (digits === undefined) { + return number.toLocaleString("ko-KR", { maximumFractionDigits: 4 }); + } + if (digits === null) { + return number.toLocaleString("ko-KR", { maximumFractionDigits: 2 }); + } + return number.toLocaleString("ko-KR", { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }); +} + +export function el( + tag: K, + className = "", + text = "", +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + if (className) node.className = className; + if (text) node.textContent = text; + return node; +} + +/** 실무 시트 머리 두 줄 — 앞 칸(명칭 따위)은 두 줄을 차지, 성분 넷은 「단가·금액」 두 칸. */ +export function sheetHead( + front: string[], + back: string[] = [L("B09_Sheet_Col_Note")], +): HTMLElement { + const thead = el("thead"); + const top = el("tr"); + const bottom = el("tr"); + for (const label of front) { + const th = el("th", "", label); + th.rowSpan = 2; + top.append(th); + } + for (const key of [ + "B09_Sheet_Col_Total", + "B09_Sheet_Col_Labor", + "B09_Sheet_Col_Material", + "B09_Sheet_Col_Expense", + ] as const) { + const th = el("th", "", L(key)); + th.colSpan = 2; + top.append(th); + bottom.append( + el("th", "", L("B09_Sheet_Col_UnitPrice")), + el("th", "", L("B09_Sheet_Col_Amount")), + ); + } + for (const label of back) { + const th = el("th", "", label); + th.rowSpan = 2; + top.append(th); + } + thead.append(top, bottom); + return thead; +} + +/** 숫자 칸 하나. */ +export function numberCell(text: string): HTMLTableCellElement { + return el("td", "b09s-num", text); +} + +/** 성분 넷 × (단가·금액) 여덟 칸 — 합계·노무·재료·경비 차례(실무 시트). */ +export function moneyCells( + unit: [string, string, string, string] | null, + amount: [string, string, string, string] | null, +): HTMLTableCellElement[] { + const cells: HTMLTableCellElement[] = []; + for (let i = 0; i < 4; i += 1) { + cells.push(numberCell(unit ? won(unit[i]) : ""), numberCell(amount ? won(amount[i]) : "")); + } + return cells; +} + +/** 표 한 장 — 가로로 넘치면 이 칸 안에서만 밀림. */ +export function sheetTable(head: HTMLElement): { wrap: HTMLElement; tbody: HTMLElement } { + const wrap = el("div", "b09s-wrap"); + const table = el("table", "b09s-table"); + const tbody = el("tbody"); + table.append(head, tbody); + wrap.append(table); + return { wrap, tbody }; +} + +/** 머리 한 줄짜리 표(목록표·집계표) — 실무 시트 칸 이름 그대로 넘김. */ +export function plainTable(headers: string[]): { wrap: HTMLElement; tbody: HTMLElement } { + const thead = el("thead"); + const tr = el("tr"); + for (const label of headers) tr.append(el("th", "", label)); + thead.append(tr); + return sheetTable(thead); +} + +/** 한 탭 안의 갈래 고르개(재료비·노무비…) — 고른 갈래를 돌려줌. */ +export function segmented( + items: Array<[string, string]>, + active: string, + onPick: (key: string) => void, +): HTMLElement { + const bar = el("div", "b09s-bar"); + for (const [key, label] of items) { + const button = el("button", `b09s-tab${key === active ? " is-active" : ""}`, label); + button.type = "button"; + button.addEventListener("click", () => onPick(key)); + bar.append(button); + } + return bar; +} + +/** 누르면 들어가는 글 — 「제 3 호표」·「단산 2」. */ +export function linkButton(text: string, onClick: () => void): HTMLButtonElement { + const button = el("button", "b09s-link", text); + button.type = "button"; + button.addEventListener("click", (event) => { + event.stopPropagation(); + onClick(); + }); + return button; +} + +/** 수동 단가 표시 — 어느 화면에서든 같게(빨간 테두리 + 「미확정 N건」). */ +export function unconfirmedBadge(count: number): HTMLElement { + return el("span", "b09s-badge", `${L("B09_Sheet_Unconfirmed")} ${count}${L("B09_Sheet_Count")}`); +} + +/** 사용자가 고친 칸 표시 + ↺(지우면 계산값으로) — B08 집계표와 같은 말·같은 꼴. */ +export function userMark(onUndo: () => void): HTMLElement { + const box = el("span", "b09s-user"); + box.append(el("span", "b09s-badge b09s-badge--user", L("B09_Sheet_User"))); + const undo = el("button", "b09s-undo", "↺"); + undo.type = "button"; + undo.title = L("B09_Sheet_Undo"); + undo.addEventListener("click", (event) => { + event.stopPropagation(); + onUndo(); + }); + box.append(undo); + return box; +} + +export function hint(text: string, warn = false): HTMLElement { + return el("div", warn ? "b09s-hint b09s-hint--warn" : "b09s-hint", text); +} + +export function injectSheetStyles(): void { + if (document.getElementById("b09-sheet-styles")) return; + const style = document.createElement("style"); + style.id = "b09-sheet-styles"; + style.textContent = ` + .b09s-page { display:flex; flex-direction:column; gap:8px; min-width:0; min-height:0; flex:1; } + .b09s-tabs { display:flex; flex-wrap:wrap; gap:4px; border-bottom:1px solid var(--ui-border, #d0d4dc); padding-bottom:4px; } + .b09s-tab { border:1px solid var(--ui-border, #d0d4dc); background:var(--ui-surface, #fff); padding:4px 10px; border-radius:6px 6px 0 0; cursor:pointer; font-size:13px; } + .b09s-tab.is-active { background:var(--ui-accent, #2f6fed); color:#fff; border-color:var(--ui-accent, #2f6fed); } + .b09s-body { display:flex; flex-direction:column; gap:8px; min-width:0; min-height:0; flex:1; overflow:auto; } + .b09s-bar { display:flex; flex-wrap:wrap; align-items:center; gap:8px; font-size:13px; } + .b09s-wrap { overflow-x:auto; max-width:100%; } + .b09s-table { border-collapse:collapse; font-size:12px; white-space:nowrap; } + .b09s-table th, .b09s-table td { border:1px solid var(--ui-border, #d0d4dc); padding:2px 6px; } + .b09s-table th { background:var(--ui-surface-muted, #f1f3f7); font-weight:600; text-align:center; } + .b09s-num { text-align:right; font-variant-numeric:tabular-nums; } + .b09s-table tr.is-group td { font-weight:600; background:var(--ui-surface-muted, #f7f8fb); } + .b09s-table tr.is-sum td { font-weight:700; background:var(--ui-surface-muted, #eef1f6); } + .b09s-table tr.is-clickable { cursor:pointer; } + .b09s-table tr.is-clickable:hover td { background:rgba(47,111,237,0.08); } + .b09s-table tr.is-selected td { background:rgba(47,111,237,0.16); } + .b09s-table tr.is-manual td { box-shadow:inset 0 0 0 1px #d93025; } + .b09s-table td.b09s-note { white-space:normal; min-width:160px; max-width:420px; } + .b09s-toggle { border:none; background:none; cursor:pointer; padding:0 4px 0 0; font-size:11px; } + .b09s-link { border:none; background:none; color:var(--ui-accent, #2f6fed); cursor:pointer; padding:0 4px 0 0; text-decoration:underline; font-size:12px; } + .b09s-badge { display:inline-block; border:1px solid #d93025; color:#d93025; border-radius:10px; padding:0 6px; font-size:11px; margin-left:4px; } + .b09s-hint { font-size:12px; color:var(--ui-text-muted, #5f6673); } + .b09s-hint--warn { color:#b3261e; } + .b09s-trail { display:flex; flex-wrap:wrap; gap:4px; align-items:center; font-size:13px; } + .b09s-split { display:flex; flex-direction:column; gap:12px; min-width:0; } + .b09s-title { font-weight:700; font-size:14px; } + .b09s-formula { white-space:pre-wrap; font-size:12px; color:var(--ui-text, #1f2430); } + .b09s-head { font-weight:600; margin-top:6px; } + .b09s-table tr.is-removed td { color:var(--ui-text-muted, #5f6673); text-decoration:line-through; } + .b09s-edit-input { width:72px; font-size:12px; padding:1px 4px; } + .b09s-edit-formula { width:220px; } + .b09s-table tr.is-user td:first-child { box-shadow:inset 3px 0 0 var(--color-accent, #6c8ebf); } + .b09s-badge--user { border-color:var(--color-accent, #6c8ebf); color:var(--color-accent, #6c8ebf); } + .b09s-undo { border:1px solid var(--ui-border, #d0d4dc); background:none; color:inherit; cursor:pointer; padding:0 4px; margin-left:4px; font-size:12px; } + .b09s-info td { text-align:right; font-variant-numeric:tabular-nums; } + .b09s-info td.b09s-left, .b09s-info th.b09s-left { text-align:left; white-space:normal; } + .b09s-group { display:flex; flex-direction:column; gap:4px; border-top:1px solid var(--ui-border, #d0d4dc); padding-top:6px; } + .b09s-inline { display:flex; align-items:center; gap:8px; flex-wrap:wrap; } + .b09s-table td.b09s-adopted { font-weight:700; color:var(--ui-accent, #2f6fed); background:rgba(47,111,237,0.08); } + .b09s-min { display:inline-block; margin-left:4px; font-size:10px; color:#1e8e3e; border:1px solid #1e8e3e; border-radius:8px; padding:0 4px; } + `; + document.head.append(style); +} diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts new file mode 100644 index 00000000..5b40dc7d --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -0,0 +1,106 @@ +/* ============================================================================= + * B09_Estimation_UI_Shell.ts + * 로그인 후 09: 6차 워크플로우 (원가계산) — 화면 **틀** (PLAN 12장 · 2026-09-14 브레인 판정) + * + * - 틀은 탭 줄과 등록만. 탭마다 파일 하나(`B09_Estimation_UI_Tab_<이름>.ts`) — 계약은 `_Shell_Types`. + * - ⚠ 등록 권한 = 랩탑_서브. 다른 창은 탭 파일을 만들고 이름을 알려 등록을 부탁함. + * - 탭을 고를 때마다 본문·좌측 칸을 비우고 `render(ctx, arg)`. `ctx.open(key, arg)` = 다른 탭으로 들어가기. + * ========================================================================== */ + +import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; +import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; +import { workflowSteps } from "../A00_Common/b_page_scaffold"; +import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav"; +import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types"; +import { L, el, injectSheetStyles } from "./B09_Estimation_UI_Sheet"; +import { costSheetTab } from "./B09_Estimation_UI_Tab_CostSheet"; +import { rateTableTab } from "./B09_Estimation_UI_Tab_RateTable"; +import { billTab } from "./B09_Estimation_UI_Tab_Bill"; +import { unitPriceTab } from "./B09_Estimation_UI_Tab_UnitPrice"; +import { priceBasisTab } from "./B09_Estimation_UI_Tab_PriceBasis"; +import { machineTab } from "./B09_Estimation_UI_Tab_Machine"; +import { priceCompareTab } from "./B09_Estimation_UI_Tab_PriceCompare"; +import { materialPricesTab } from "./B09_Estimation_UI_Tab_MaterialPrices"; +import { summaryTab } from "./B09_Estimation_UI_Tab_Summary"; +import { listsTab } from "./B09_Estimation_UI_Tab_Lists"; +import { designDocTab } from "./B09_Estimation_UI_Tab_DesignDoc"; +import { basisSheetTab } from "./B09_Estimation_UI_Tab_BasisSheet"; +import { supplyTab } from "./B09_Estimation_UI_Tab_Supply"; +import { baseDataTab } from "./B09_Estimation_UI_Tab_BaseData"; +import { contractTab } from "./B09_Estimation_UI_Tab_Contract"; +import { executionTab } from "./B09_Estimation_UI_Tab_Execution"; +import { progressTab } from "./B09_Estimation_UI_Tab_Progress"; +import { completionTab } from "./B09_Estimation_UI_Tab_Completion"; + +/** 탭 등록 — 한 줄에 탭 하나. */ +const TABS: B09Tab[] = [ + costSheetTab, // 랩탑_메인 + rateTableTab, // 랩탑_메인 + billTab, + unitPriceTab, + priceBasisTab, + machineTab, + priceCompareTab, + materialPricesTab, // 랩탑_메인 — 자재단가대비표 옆(자재 수동 단가) + summaryTab, + listsTab, + supplyTab, + baseDataTab, + designDocTab, + basisSheetTab, + contractTab, // 랩탑_메인 — 설계 뒤 계약 단계라 맨 끝 + executionTab, // 랩탑_메인 — 계약 뒤 실행 단계 + progressTab, // 랩탑_메인 — 실행 뒤 기성 단계 + completionTab, // 랩탑_메인 — 기성 뒤 준공 단계 +]; + +export async function renderB09Estimation(root: HTMLElement): Promise { + injectSheetStyles(); + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + const page = el("div", "b09s-page"); + const bar = el("div", "b09s-tabs"); + const body = el("div", "b09s-body"); + const panel = el("div"); + page.append(bar, body); + + let active = TABS[0].key; + const open = (key: string, arg?: string): void => { + const tab = TABS.find((item) => item.key === key); + if (!tab) return; + active = key; + drawBar(); + body.replaceChildren(); + panel.replaceChildren(); + const ctx: B09TabContext = { projectId, body, panel, root, open }; + tab.render(ctx, arg); + }; + const drawBar = (): void => { + bar.replaceChildren( + ...TABS.map((tab) => { + const button = el( + "button", + `b09s-tab${tab.key === active ? " is-active" : ""}`, + tab.label(), + ); + button.type = "button"; + button.dataset.tab = tab.key; + button.addEventListener("click", () => open(tab.key)); + return button; + }), + ); + }; + + const layout = createWorkflowLayout({ + title: L("B09_Estimation_Title"), + steps: workflowSteps(), + activeStep: 6, + leftPanel: panel, + mainContent: page, + routes: WORKFLOW_STEP_ROUTES, + onStepClick: (stepIndex) => { + if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]); + }, + }); + root.append(layout.root); + open(active); +} diff --git a/B09_Estimation/B09_Estimation_UI_Shell_Types.ts b/B09_Estimation/B09_Estimation_UI_Shell_Types.ts new file mode 100644 index 00000000..c2d67b50 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Shell_Types.ts @@ -0,0 +1,33 @@ +/* ============================================================================= + * B09_Estimation_UI_Shell_Types.ts + * B09 원가계산 화면 틀 ↔ 탭 파일 계약 (PLAN 12장 · 2026-09-14 브레인 판정) + * + * - 탭마다 파일 하나: `B09_Estimation_UI_Tab_<이름>.ts` 가 `B09Tab` 하나를 내보냄. + * - 틀(`B09_Estimation_UI_Shell.ts`)은 **등록 한 줄씩**만 가짐. 등록 권한 = 랩탑_서브. + * - 틀은 탭을 고를 때마다 `body`·`panel` 을 비우고 `render(ctx, arg)` 를 부름. + * 탭의 자료·상태는 탭 파일이 스스로 들고 있음(다시 그릴 때 다시 받을지도 탭이 정함). + * - [재계산]·[확정]·[저장] 같은 단추는 탭이 자기 `panel`·`body` 에 둠 — 틀에 훅 없음. + * - ⚠ 화면이 값을 만들지 않음 — 표시·입력만. 계산은 서버. + * ========================================================================== */ + +export interface B09TabContext { + /** 지금 프로젝트 — 없으면 탭이 「프로젝트를 고르세요」 식으로 비워 둠. */ + projectId: string | null; + /** 탭 본문 자리 — 고를 때마다 비워서 줌. */ + body: HTMLElement; + /** 좌측 칸 자리 — 고를 때마다 비워서 줌. 안 쓰면 비워 둠. */ + panel: HTMLElement; + /** 페이지 뿌리 — 근거 토글(`createProvenanceToggle`) 따위가 씀. */ + root: HTMLElement; + /** 다른 탭으로 들어가기 — 내역 줄 → 일위대가 호표 → 단가산출근거처럼. `arg` 는 받는 탭이 읽음. */ + open: (key: string, arg?: string) => void; +} + +export interface B09Tab { + /** 탭 id — 틀 안에서 유일(`cost_sheet` · `boq` · `unit_price` · `price_basis` …). */ + key: string; + /** 탭 제목 — 언어 바뀜을 따르게 함수로. */ + label: () => string; + /** 고를 때마다 불림. `arg` = 다른 탭이 `open(key, arg)` 로 넘긴 값(없으면 undefined). */ + render: (ctx: B09TabContext, arg?: string) => void; +} diff --git a/B09_Estimation/B09_Estimation_UI_Store.ts b/B09_Estimation/B09_Estimation_UI_Store.ts new file mode 100644 index 00000000..1d415ee4 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Store.ts @@ -0,0 +1,281 @@ +/* ============================================================================= + * B09_Estimation_UI_Store.ts + * B09 내역서·일위대가·단가산출근거 탭이 **한 벌로** 쓰는 서버 자료 (PLAN 12장) + * + * - 설계내역서 응답 한 벌에 호표 목록(`unit_price_sheet`)·단산 목록(`price_basis`)이 함께 옴 — + * 세 탭이 같은 응답을 봄(번호가 탭끼리 갈리지 않게). 번호는 서버가 낼 때마다 매김(저장 안 함). + * - 캐시는 메모리 한 벌(프로젝트별) — [다시 불러오기] 가 비움. 값을 저장하지 않음(보기 전용 1차). + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import type { ProvenancePayload } from "@ui/ui_template_provenance"; +import type { RowEditDto } from "./B09_Estimation_UI_DetailEdit"; + +export interface BillNoteDto { + column: string; + text: string; +} + +export interface BillRowDto { + item_no: string; + level: number; + code: string | null; + name: string; + spec: string; + unit: string; + quantity: string | null; + quantity_shown: string | null; + quantity_digits: number | null; + unit_price_krw: string | null; + amount_krw: string | null; + material_krw: string; + labor_krw: string; + expense_krw: string; + unit_material_krw: string | null; + unit_labor_krw: string | null; + unit_expense_krw: string | null; + price_code: string; + unconfirmed: number; + price_basis_label: string; + /** 할증 칸 이름 · 얹힌 율(사용자, 12장 2차 ④) — `source` = 줄 칸 또는 `*`(내역서 전체). */ + rate_key: string; + rate: { source: string; material: string; labor: string; expense: string } | null; + is_group: boolean; + in_bill: boolean; + note: string; + notes: BillNoteDto[]; +} + +export interface SheetEntryDto { + number: number; + label: string; + code: string; + name: string; + spec: string; + unit: string; + material_krw: string; + labor_krw: string; + expense_krw: string; + total_krw?: string; + unit_price_krw?: string; + source?: string; + unconfirmed?: number; + ref_code?: string; + unit_price_codes?: string[]; +} + +export interface MissingDto { + name: string; + unit?: string; + quantity?: string; + reason: string; + code?: string; + blocked_kind?: string; +} + +/** 집계표·목록표 한 줄 — 서버 `resource_summary`·`bill_lists` 그대로. */ +export interface ResourceRowDto { + number: number; + code: string; + name: string; + spec: string; + unit: string; + quantity?: string; + unit_price_krw: string | null; + amount_krw?: string | null; + labor_krw?: string; + material_krw?: string; + expense_krw?: string; + unit_labor_krw?: string; + unit_material_krw?: string; + unit_expense_krw?: string; + note: string; +} + +export type ResourceGroup = "labor" | "material" | "expense" | "machine" | "lumpsum"; + +export interface MaterialSheetRowDto { + name: string; + spec: string; + unit: string; + total_amount: string; + unit_price_krw: string | null; + amount_krw: string | null; + note: string; +} + +export interface MaterialSheetDto { + contractor: MaterialSheetRowDto[]; + owner: MaterialSheetRowDto[]; + unknown: MaterialSheetRowDto[]; + contractor_total_krw: string; + owner_total_krw: string; + notes: string[]; +} + +export interface BillDto { + /** 근거 사전 — 개발환경에서만 옴. */ + provenance?: ProvenancePayload; + resources: { + groups: Record; + missing: string[]; + note: string; + }; + lists: Record; + rows: BillRowDto[]; + excluded: BillRowDto[]; + summary: { + body_total_krw: string; + detail_rows: number; + missing: MissingDto[]; + unconfirmed_count: number; + /** 치수 없이 기본값으로 선 구조물 줄 — 제자리에 빈 금액으로 서고 합계에 안 듦. */ + unpriced_count?: number; + notes: string[]; + material_sheet: MaterialSheetDto | null; + }; + price_basis: { entries: SheetEntryDto[] }; + unit_price_sheet: { entries: SheetEntryDto[] }; +} + +export interface DetailRowDto { + ref_code?: string; + code?: string; + name: string; + spec: string; + unit: string; + kind?: string; + source_label?: string; + source?: string; + drillable: boolean; + quantity: string; + unit_material?: string; + unit_labor?: string; + unit_expense?: string; + unit_total?: string; + material: string; + labor: string; + expense: string; + total: string; + note: string; + /** 고친 값 표시(편집 문으로 받을 때만) — 2차 편집. */ + edit?: RowEditDto; +} + +export interface DetailDto { + code: string; + name: string; + spec: string; + unit: string; + kind: string; + material: string; + labor: string; + expense: string; + total: string; + rows: DetailRowDto[]; + unattached_note: string; + known_gap_note: string; + /** 구성행·Q 식을 고칠 수 있는 본표(B·D)인가 · 줄 더하기에 쓸 다음 칸 이름. */ + editable?: boolean; + next_add_key?: string; +} + +const bills = new Map>(); + +async function getJson(path: string): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include" }); + const body = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(String((body as { message?: string }).message || response.status)); + } + return body as T; +} + +/** 사용자가 고친 값 한 벌(프로젝트 단위 · 서버 `estimation.edits`). 구획 → {칸: 값}. */ +export type EditsDto = Record>; + +export interface EditChange { + section: string; + key: string; + /** `null` = 그 칸을 지움(↺ 계산값으로 돌아감). */ + value: unknown; +} + +export async function loadEdits( + projectId: string, +): Promise<{ edits: EditsDto; skipped: string[] }> { + return getJson(`/projects/${encodeURIComponent(projectId)}/estimation/edits`); +} + +/** 고친 값 저장 — 금액은 서버가 다시 계산하므로 내역 한 벌도 비움. 받지 못하면 까닭을 던짐. */ +export async function saveEdits(projectId: string, changes: EditChange[]): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/edits`, + { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ changes }), + }, + ); + const body = (await response.json().catch(() => ({}))) as { message?: string; edits?: EditsDto }; + if (!response.ok) throw new Error(body.message || String(response.status)); + forgetBill(projectId); + return body.edits ?? {}; +} + +/** 산출 조건을 저장한 뒤 — 단가가 다시 서므로 다음에 고르는 탭이 새로 받게 비움. */ +export function forgetBill(projectId: string): void { + bills.delete(projectId); +} + +/** 설계내역서 한 벌 — 세 탭이 같은 응답을 봄. `force` 면 다시 받음. */ +export function loadBill(projectId: string, force = false): Promise { + if (force || !bills.has(projectId)) { + const request = getJson(`/projects/${encodeURIComponent(projectId)}/estimation/bill`); + request.catch(() => bills.delete(projectId)); + bills.set(projectId, request); + } + return bills.get(projectId) as Promise; +} + +export interface PriceSlotDto { + name: string; + price_krw: string | null; + source_note: string; + adopted: boolean; +} + +export interface PriceCompareDto { + material_comparison: { + slot_names: string[]; + rows: Array<{ + code: string; + name: string; + spec: string; + unit: string; + slots: PriceSlotDto[]; + min_slot: number | null; + adopted_slot: number; + adopted_price_krw: string | null; + note: string; + }>; + notes: string[]; + }; +} + +/** 자재단가대비표 — 슬롯 여섯 · 채택 · 최소단가(서버 `material_price_comparison`). */ +export function loadPriceCompare(projectId: string): Promise { + return getJson( + `/projects/${encodeURIComponent(projectId)}/estimation/price-sources`, + ); +} + +/** 호표 본표 — 일위대가(B)·단가산출(D)은 편집 문(줄마다 고친 값 표시), 시간당 중기(X)는 `unit-prices`. */ +export function loadDetail(projectId: string, code: string): Promise { + const path = + code.startsWith("B-") || code.startsWith("D-") + ? `edits/sheet/${encodeURIComponent(code)}` + : `unit-prices/${encodeURIComponent(code)}`; + return getJson(`/projects/${encodeURIComponent(projectId)}/estimation/${path}`); +} diff --git a/B09_Estimation/B09_Estimation_UI_Tab_BaseData.ts b/B09_Estimation/B09_Estimation_UI_Tab_BaseData.ts new file mode 100644 index 00000000..546e549d --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tab_BaseData.ts @@ -0,0 +1,376 @@ +/* ============================================================================= + * B09_Estimation_UI_Tab_BaseData.ts + * B09 기초자료 탭 — 산출 조건(계산 입력) · 노무비·재료비·경비 목록표 · 자재단가대비표 · 환율및기초자료 + * + * - 옛 `B09_Estimation_UI_BaseData.ts`·`_UI_Page.ts` 의 기초자료 탭을 그대로 옮김(PLAN 12장 옛 탭 옮기기). + * - ⚠ 산출 조건·유가 지역은 **계산 입력** — 저장하면 단가가 다시 서므로 이 탭 자료와 + * 설계내역서 한 벌(`UI_Store`)을 함께 새로 받음. + * - 표를 냈는데 화면에 없으면 낸 것이 아님 — 목록표 셋·대비표·기초자료를 한 탭에 모두 보임. + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { + markProvenanceCell, + attachProvenance, + type ProvenancePayload, + type ProvenanceSheet, +} from "@ui/ui_template_provenance"; +import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types"; +import { + drawFactorChoices, + fetchFactorChoices, + picker, + saveFactorChoices, +} from "./B09_Estimation_UI_Factors"; +import type { FactorChoicesDto } from "./B09_Estimation_UI_Factors"; +import { L, el, hint } from "./B09_Estimation_UI_Sheet"; +import { forgetBill } from "./B09_Estimation_UI_Store"; +import { head, infoTable, money, note } from "./B09_Estimation_UI_Table"; + +interface BaseDataRow { + code: string; + name: string; + spec: string; + unit: string; + unit_price_krw: string | null; + note: string; +} + +interface BaseDataDto { + labor: BaseDataRow[]; + material: BaseDataRow[]; + expense: BaseDataRow[]; + provenance?: ProvenancePayload; +} + +interface PriceSlot { + name: string; + price_krw: string | null; + source_note: string; + adopted: boolean; +} + +interface MaterialComparisonRow { + code: string; + name: string; + spec: string; + unit: string; + slots: PriceSlot[]; + adopted_slot: number; + adopted_price_krw: string | null; + note: string; +} + +interface FuelScope { + key: string; + label: string; + available: boolean; + why?: string; +} + +interface PriceSourcesDto { + provenance?: ProvenancePayload; + material_comparison: { slot_names: string[]; rows: MaterialComparisonRow[]; notes: string[] }; + base_reference: { + exchange: { note: string }; + labor: { + rows: Array<{ + code: string; + name: string; + day_wage_krw: string | null; + hourly_krw: string | null; + formula: string; + }>; + note: string; + }; + fuel: { + diesel_krw_per_l: string | null; + scope: string; + effective_date: string; + dataset_id: string; + scopes: FuelScope[]; + regions: Array<{ code: string; name: string; diesel_krw_per_l: string | null }>; + region: string; + region_name: string; + region_missing?: string; + note: string; + }; + }; +} + +async function getJson(projectId: string, path: string): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/${path}`, + { credentials: "include" }, + ); + if (!response.ok) throw new Error(`${path} ${response.status}`); + return (await response.json()) as T; +} + +/** + * 목록표 셋 — 코드·명칭·규격·단위·단가·비고. + * ⚠ 표가 비거나 한 줄뿐일 때 그냥 두지 않음 — 「다 채운 것」으로 읽힘(재료비목록표가 그 자리). + */ +function drawCatalogs(body: HTMLElement, data: BaseDataDto): void { + const groups: Array<[string, BaseDataRow[], string]> = [ + ["노무비목록표", data.labor, ""], + [ + "재료비목록표", + data.material, + data.material.length <= 1 + ? "⚠ 사급 자재 카탈로그가 아직 서지 않아 줄이 거의 없습니다 — 자재값 출처(업체 견적·물가지)를 붙이면 채워집니다." + : "", + ], + ["경비목록표", data.expense, "기계 취득가격입니다(천원) — 시간당 사용료는 「중기」 탭입니다."], + ]; + for (const [title, rows, text] of groups) { + body.append(head(`${title} (${rows.length})`)); + if (text) body.append(note(text)); + if (rows.length === 0) { + body.append(note("아직 선 줄이 없습니다 — 값을 0 으로 채우지 않습니다.")); + continue; + } + body.append( + infoTable( + ["코드번호", "명 칭", "규 격", "단위", "단 가", "비 고"], + rows.map((row) => [ + row.code, + row.name, + row.spec, + row.unit, + money(row.unit_price_krw), + row.note, + ]), + [0, 1, 2, 5], + ["code", "name", "spec", "unit", "unit_price_krw", "note"], + data.provenance?.sheets?.catalog, + ), + ); + } +} + +/** 자재단가대비표 — 원천마다 **단가·페이지** 두 칸, 채택한 원천을 굵게. */ +function comparisonTable( + slotNames: string[], + rows: MaterialComparisonRow[], + sheet?: ProvenanceSheet, +): HTMLElement { + const wrap = el("div", "b09s-wrap"); + const table = el("table", "b09s-table b09s-info"); + const thead = el("thead"); + const top = el("tr"); + const bottom = el("tr"); + // ⚠ 슬롯 6 이 곧 「적용 단가」(`JUKNM=6`) — 그 자리에 「적 용」 칸을 또 세우면 같은 값이 두 번 섬. + const appliedIsLastSlot = + rows.length > 0 && rows.every((row) => row.adopted_slot === slotNames.length); + ["코드번호", "명 칭", "규 격", "단위"].forEach((text, index) => { + const th = el("th", index <= 2 ? "b09s-left" : "", text); + th.rowSpan = 2; + top.append(th); + }); + for (const name of [...slotNames, ...(appliedIsLastSlot ? [] : ["적 용"])]) { + const th = el("th", "", name); + th.colSpan = 2; + top.append(th); + bottom.append(el("th", "", "단 가"), el("th", "", "페이지")); + } + const noteHead = el("th", "b09s-left", "비 고"); + noteHead.rowSpan = 2; + top.append(noteHead); + thead.append(top, bottom); + + const tbody = el("tbody"); + for (const row of rows) { + const tr = el("tr"); + const mark = (td: HTMLElement, key: string, tier?: string): void => { + const column = sheet?.columns[key]; + if (column) markProvenanceCell(td, key, tier ?? column.tier); + }; + const put = (text: string, left = false, key?: string): void => { + const td = el("td", left ? "b09s-left" : "", text); + if (key) mark(td, key); + tr.append(td); + }; + put(row.code, true, "code"); + put(row.name, true, "name"); + put(row.spec, true, "spec"); + put(row.unit, false, "unit"); + for (const slot of row.slots) { + const td = el("td", slot.adopted ? "b09s-adopted" : "", money(slot.price_krw)); + // ⚠ 빈 칸은 「0원」이 아니라 「그 판에 그 품목이 없다」 — 막힌 자리로 표시. + mark(td, "slot_price", slot.price_krw === null ? "blocked" : undefined); + const page = el("td", "b09s-left", slot.source_note); + mark(page, "slot_page"); + tr.append(td, page); + } + if (!appliedIsLastSlot) { + put(money(row.adopted_price_krw), false, "adopted_price_krw"); + put(row.adopted_slot ? (row.slots[row.adopted_slot - 1]?.name ?? "") : "", true); + } + put(row.note, true, "note"); + tbody.append(tr); + } + table.append(thead, tbody); + attachProvenance(table, sheet); + wrap.append(table); + return wrap; +} + +/** 환율및기초자료 — 실무 시트 세 구획(환율·인건비·단가 및 재료비) + 유가 지역 고르개(계산 입력). */ +function drawReference( + body: HTMLElement, + data: PriceSourcesDto, + projectId: string, + reload: () => void, +): void { + const reference = data.base_reference; + const sheets = data.provenance?.sheets; + body.append(head("환율및기초자료 — ① 환율")); + body.append(note(reference.exchange.note)); + + body.append(head(`환율및기초자료 — ② 인건비 (${reference.labor.rows.length})`)); + if (reference.labor.rows.length === 0) { + body.append(note("아직 선 줄이 없습니다 — 값을 0 으로 채우지 않습니다.")); + } else { + body.append( + infoTable( + ["코드번호", "직 종", "일 당", "시간당", "산 식"], + reference.labor.rows.map((row) => [ + row.code, + row.name, + money(row.day_wage_krw), + money(row.hourly_krw), + row.formula, + ]), + [0, 1, 4], + ["code", "name", "day_wage_krw", "hourly_krw", "formula"], + sheets?.base_reference_labor, + ), + ); + } + body.append( + note( + "시간당은 나눈 값을 그대로 둡니다 — 여기서 원 단위로 자르면 기계 시간당 사용료가 " + + "조금씩 어긋납니다. 자르는 자리는 일위대가·내역서 쪽입니다.", + ), + ); + body.append(note(reference.labor.note)); + + body.append(head("환율및기초자료 — ③ 단가 및 재료비")); + const fuel = reference.fuel; + body.append( + infoTable( + ["항 목", "단 가", "적용 범위", "기준일", "자료"], + [ + [ + "경유", + money(fuel.diesel_krw_per_l), + fuel.scopes.find((scope) => scope.key === fuel.scope)?.label || fuel.scope, + fuel.effective_date, + fuel.dataset_id, + ], + ], + [0, 2, 3, 4], + ["item", "price_krw", "scope", "effective_date", "dataset_id"], + sheets?.base_reference_fuel, + ), + ); + // ⚠ 확정 ⑮ — 전국/시도를 한 칸에서 고름. 자료가 없으면 고를 수 없게 두고 까닭을 밝힘. + const options = [ + { key: "", label: "전국 공시가" }, + ...fuel.regions.map((region) => ({ + key: region.code, + label: `${region.name} ${region.diesel_krw_per_l ?? ""}원/L`, + })), + ]; + const fuelPicker = picker("유가 적용 범위", options, fuel.region, (key) => { + void saveFactorChoices(projectId, { fuel_region: key }) + .then(reload) + .catch((error: Error) => body.append(note(`⚠ ${error.message}`))); + }); + const select = fuelPicker.querySelector("select"); + if (select) select.disabled = fuel.regions.length === 0; + body.append(fuelPicker); + if (fuel.regions.length === 0) { + for (const scope of fuel.scopes) { + if (!scope.available && scope.why) body.append(note(`⚠ ${scope.label}: ${scope.why}`)); + } + } else { + body.append( + note( + fuel.region + ? `${fuel.region_name} 공시가로 서 있습니다 — 기계 연료비가 그 값으로 다시 섭니다.` + : "전국 공시가로 서 있습니다 — 현장 시도를 고르면 그 지역 값으로 바뀝니다.", + ), + ); + } + if (fuel.region_missing) body.append(note(`⚠ ${fuel.region_missing}`)); + body.append(note(fuel.note)); +} + +function drawSources( + body: HTMLElement, + data: PriceSourcesDto, + projectId: string, + reload: () => void, +): void { + const comparison = data.material_comparison; + body.append(head(`자재단가대비표 (${comparison.rows.length})`)); + if (comparison.rows.length <= 1) { + body.append( + note( + "⚠ 사급 자재 카탈로그가 아직 서지 않아 줄이 거의 없습니다 — 재료비목록표와 같은 원인입니다.", + ), + ); + } + if (comparison.rows.length === 0) { + body.append(note("아직 선 줄이 없습니다 — 값을 0 으로 채우지 않습니다.")); + } else { + body.append( + comparisonTable( + comparison.slot_names, + comparison.rows, + data.provenance?.sheets?.material_comparison, + ), + ); + } + for (const text of comparison.notes) body.append(note(text)); + drawReference(body, data, projectId, reload); +} + +function show(ctx: B09TabContext, projectId: string): void { + // 저장 뒤 — 이 탭 자료와 내역 한 벌을 함께 새로 받음(값이 다시 섬). + const reload = (): void => { + forgetBill(projectId); + ctx.body.replaceChildren(); + show(ctx, projectId); + }; + ctx.body.append(hint(L("B09_Sheet_Loading"))); + void Promise.allSettled([ + fetchFactorChoices(projectId), + getJson(projectId, "base-data"), + getJson(projectId, "price-sources"), + ]).then(([factors, lists, sources]) => { + ctx.body.replaceChildren(); + // 산출 조건이 목록표보다 **먼저** — 값을 낳는 자리가 아래 있으면 「바꿀 수 있는 것」을 못 봄. + if (factors.status === "fulfilled") { + drawFactorChoices(ctx.body, factors.value as FactorChoicesDto, projectId, reload); + } + if (lists.status === "fulfilled") drawCatalogs(ctx.body, lists.value); + else ctx.body.append(hint(`${L("B09_Sheet_LoadFailed")} base-data`, true)); + if (sources.status === "fulfilled") drawSources(ctx.body, sources.value, projectId, reload); + else ctx.body.append(hint(`${L("B09_Sheet_LoadFailed")} price-sources`, true)); + }); +} + +export const baseDataTab: B09Tab = { + key: "base_data", + label: () => L("B09_Estimation_Tab_BaseData"), + render(ctx) { + if (!ctx.projectId) { + ctx.body.append(hint(L("B09_Sheet_NoProject"))); + return; + } + show(ctx, ctx.projectId); + }, +}; diff --git a/B09_Estimation/B09_Estimation_UI_Tab_BasisSheet.ts b/B09_Estimation/B09_Estimation_UI_Tab_BasisSheet.ts new file mode 100644 index 00000000..3b8a1c0d --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tab_BasisSheet.ts @@ -0,0 +1,120 @@ +/* ============================================================================= + * B09_Estimation_UI_Tab_BasisSheet.ts + * B09 산출기초 탭 — 줄에 달린 근거를 한 장으로 모은 장(별표2 (5)(가) 열셋째) + * + * - 옛 `B09_Estimation_UI_BaseData.ts` 의 산출기초를 그대로 옮김(PLAN 12장 옛 탭 옮기기). + * - ⚠ 여기서 값을 다시 계산하지 않음 — 모으기만 함. 네 구획: 판 · 고른 값 · 공종별 근거 · 못 채운 자리. + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import type { ProvenancePayload } from "@ui/ui_template_provenance"; +import type { B09Tab } from "./B09_Estimation_UI_Shell_Types"; +import { L, hint } from "./B09_Estimation_UI_Sheet"; +import { head, infoTable, note } from "./B09_Estimation_UI_Table"; + +interface BasisSheetDto { + provenance?: ProvenancePayload; + note: string; + summary: string; + dataset_versions: Array<{ + dataset_id: string; + file: string; + effective_date: string; + sha256: string; + }>; + chosen_conditions: Array<{ item: string; value: string }>; + work_items: Array<{ code: string; name: string; unit: string; notes: string[] }>; + gaps: Array<{ kind: string; code: string; reason: string }>; +} + +function draw(body: HTMLElement, data: BasisSheetDto): void { + const sheets = data.provenance?.sheets; + body.append(head("산출기초")); + body.append(note(data.summary)); + body.append(note(data.note)); + + body.append(head(`① 어느 판으로 계산했나 (${data.dataset_versions.length})`)); + body.append( + infoTable( + ["자료", "파일", "기준일", "지문(앞 12)"], + data.dataset_versions.map((row) => [ + row.dataset_id, + row.file, + row.effective_date, + row.sha256 || "—", + ]), + [0, 1, 2, 3], + ["dataset_id", "file", "effective_date", "sha256"], + sheets?.basis_versions, + ), + ); + + body.append(head(`② 무엇을 골랐나 (${data.chosen_conditions.length})`)); + if (data.chosen_conditions.length === 0) { + body.append(note("고른 값이 없습니다 — 전부 확정 기본값으로 돌고 있습니다.")); + } else { + body.append( + infoTable( + ["항 목", "고른 값"], + data.chosen_conditions.map((row) => [row.item, row.value]), + [0, 1], + ["item", "value"], + sheets?.basis_chosen, + ), + ); + } + + body.append(head(`③ 공종마다 무엇을 근거로 했나 (${data.work_items.length})`)); + body.append( + infoTable( + ["코드", "공 종", "단위", "근 거"], + data.work_items.map((row) => [row.code, row.name, row.unit, row.notes.join(" · ")]), + [0, 1, 2, 3], + ["code", "name", "unit", "notes"], + sheets?.basis_items, + ), + ); + + body.append(head(`④ 못 채운 자리 (${data.gaps.length})`)); + if (data.gaps.length === 0) { + body.append(note("못 채운 자리가 없습니다.")); + return; + } + body.append( + infoTable( + ["갈 래", "코드", "사 유"], + data.gaps.map((row) => [row.kind, row.code, row.reason]), + [0, 1, 2], + ["kind", "code", "reason"], + sheets?.basis_gaps, + ), + ); + body.append(note("⚠ 여기 있는 것은 0 으로 때우지 않고 남겨 둔 자리입니다.")); +} + +export const basisSheetTab: B09Tab = { + key: "basis_sheet", + label: () => L("B09_Estimation_Tab_BasisSheet"), + render(ctx) { + if (!ctx.projectId) { + ctx.body.append(hint(L("B09_Sheet_NoProject"))); + return; + } + // 고른 값이 바뀌면 근거도 바뀜 — 고를 때마다 새로 받음. + ctx.body.append(hint(L("B09_Sheet_Loading"))); + fetch(`${API_BASE_URL}/projects/${encodeURIComponent(ctx.projectId)}/estimation/basis-sheet`, { + credentials: "include", + }) + .then((response) => { + if (!response.ok) throw new Error(String(response.status)); + return response.json() as Promise; + }) + .then((data) => { + ctx.body.replaceChildren(); + draw(ctx.body, data); + }) + .catch((error: Error) => { + ctx.body.replaceChildren(hint(`${L("B09_Sheet_LoadFailed")} ${error.message}`, true)); + }); + }, +}; diff --git a/B09_Estimation/B09_Estimation_UI_Tab_Bill.ts b/B09_Estimation/B09_Estimation_UI_Tab_Bill.ts new file mode 100644 index 00000000..56458849 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tab_Bill.ts @@ -0,0 +1,281 @@ +/* ============================================================================= + * B09_Estimation_UI_Tab_Bill.ts + * B09 설계내역서 탭 — STmate `wBoq` 본 (PLAN 12장 1차: 표시 + 들어가기) + * + * - 열은 실무 설계내역서 그대로: 공종 · 명칭 · 규격 · 수량 · 단위 · 합계/노무비/재료비/경비(단가·금액) · 비고. + * - 머리글 줄은 ▸/▾ 로 접고, 「레벨」 고르개로 한 번에 접음(STmate `소계~총대계` 고르기의 우리 꼴). + * - ⭐ 줄을 누르면 그 줄 호표(일위대가) → 호표 안 줄을 누르면 단가산출근거로 들어감(브레인 판정 핵심). + * - 수동 단가로 선 줄 = 빨간 테두리 + 「미확정 N건」. + * - ⚠ 금액·합계는 서버가 실은 값 — 여기서 더하지 않음(머리글 줄 금액도 서버 합). + * ========================================================================== */ + +import { createButton, showToast } from "@ui/ui_template_elements"; +import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types"; +import { + L, + el, + hint, + linkButton, + moneyCells, + numberCell, + quantity, + sheetHead, + sheetTable, + unconfirmedBadge, + won, +} from "./B09_Estimation_UI_Sheet"; +import { loadBill, type BillDto, type BillRowDto } from "./B09_Estimation_UI_Store"; +import { rateForm, rateMark } from "./B09_Estimation_UI_BillRates"; + +/** 접힌 머리글 줄(공종 번호) — 탭을 오가도 남음. */ +const collapsed = new Set(); +let levelLimit = 0; // 0 = 모두 +/** 할증·일괄보정 칸을 켰는가 · 고른 줄(할증 칸 이름) — 2차 ④. */ +let rateMode = false; +const selectedRates = new Set(); + +function rowMoney(row: BillRowDto): { + unit: [string, string, string, string] | null; + amount: [string, string, string, string] | null; +} { + const amount: [string, string, string, string] | null = + row.amount_krw === null + ? null + : [row.amount_krw, row.labor_krw, row.material_krw, row.expense_krw]; + if (row.is_group || row.unit_price_krw === null) return { unit: null, amount }; + return { + unit: [ + row.unit_price_krw, + row.unit_labor_krw ?? "", + row.unit_material_krw ?? "", + row.unit_expense_krw ?? "", + ], + amount, + }; +} + +function isHidden(row: BillRowDto, rows: BillRowDto[]): boolean { + if (levelLimit > 0 && row.level > levelLimit) return true; + for (const group of rows) { + if ( + group.is_group && + collapsed.has(group.item_no) && + row.item_no.startsWith(`${group.item_no}-`) + ) { + return true; + } + } + return false; +} + +function noteCell( + row: BillRowDto, + bill: BillDto, + ctx: B09TabContext, + reload: () => void, +): HTMLTableCellElement { + const cell = el("td", "b09s-note"); + const mark = ctx.projectId ? rateMark(ctx.projectId, row, reload) : null; + if (mark) cell.append(mark); + const sheet = bill.unit_price_sheet.entries.find((entry) => entry.code === row.price_code); + if (sheet) cell.append(linkButton(sheet.label, () => ctx.open("unit_price", sheet.code))); + const basis = bill.price_basis.entries.filter((entry) => + (entry.unit_price_codes ?? []).includes(row.price_code), + ); + for (const entry of basis) { + cell.append( + linkButton(`${L("B09_Sheet_Basis_Short")} ${entry.number}`, () => + ctx.open("price_basis", entry.code), + ), + ); + } + if (row.unconfirmed > 0) cell.append(unconfirmedBadge(row.unconfirmed)); + // 비고 글 — 단산 번호는 위 단추로 보였으니 그 조각만 뺌. + const rest = row.notes + .map((note) => note.text) + .filter((text) => text && text !== row.price_basis_label) + .join(" / "); + if (rest) cell.append(el("span", "", rest)); + return cell; +} + +function drawRows( + tbody: HTMLElement, + bill: BillDto, + ctx: B09TabContext, + redraw: () => void, + reload: () => void, +): void { + for (const row of bill.rows) { + if (isHidden(row, bill.rows)) continue; + const tr = el("tr", row.is_group ? "is-group" : ""); + const numberTd = el("td"); + if (row.is_group) { + const toggle = el("button", "b09s-toggle", collapsed.has(row.item_no) ? "▸" : "▾"); + toggle.type = "button"; + toggle.addEventListener("click", (event) => { + event.stopPropagation(); + if (collapsed.has(row.item_no)) collapsed.delete(row.item_no); + else collapsed.add(row.item_no); + redraw(); + }); + numberTd.append(toggle); + } + if (rateMode && !row.is_group && row.amount_krw !== null) { + // 할증 범위 「선택 항목만」 — 고른 줄의 할증 칸 이름을 모음. + const pick = el("input"); + pick.type = "checkbox"; + pick.checked = selectedRates.has(row.rate_key); + pick.addEventListener("click", (event) => event.stopPropagation()); + pick.addEventListener("change", () => { + if (pick.checked) selectedRates.add(row.rate_key); + else selectedRates.delete(row.rate_key); + redraw(); + }); + numberTd.append(pick); + } + numberTd.append(document.createTextNode(row.item_no)); + const name = el("td", "", row.name); + name.style.paddingLeft = `${6 + Math.max(0, row.level - 1) * 12}px`; + const { unit, amount } = rowMoney(row); + tr.append( + numberTd, + name, + el("td", "", row.spec), + numberCell( + row.is_group ? "" : quantity(row.quantity_shown ?? row.quantity, row.quantity_digits), + ), + el("td", "", row.unit), + ...moneyCells(unit, amount), + ); + if (row.is_group) { + tr.append(el("td")); + } else { + tr.append(noteCell(row, bill, ctx, reload)); + if (row.rate) tr.classList.add("is-user"); + if (row.unconfirmed > 0) tr.classList.add("is-manual"); + if (row.price_code) { + tr.classList.add("is-clickable"); + tr.title = L("B09_Sheet_Open_UnitPrice"); + tr.addEventListener("click", () => ctx.open("unit_price", row.price_code)); + } + } + tbody.append(tr); + } +} + +function levelPicker(bill: BillDto, redraw: () => void): HTMLElement { + const select = el("select", "ui-input"); + const deepest = Math.max(1, ...bill.rows.map((row) => row.level)); + const all = el("option", "", L("B09_Sheet_Level_All")); + all.value = "0"; + select.append(all); + for (let level = 1; level < deepest; level += 1) { + const option = el("option", "", `${level}${L("B09_Sheet_Level_Upto")}`); + option.value = String(level); + select.append(option); + } + select.value = String(levelLimit); + select.addEventListener("change", () => { + levelLimit = Number(select.value); + redraw(); + }); + const label = el("label", "b09s-bar", L("B09_Sheet_Level")); + label.append(select); + return label; +} + +function drawBill(ctx: B09TabContext, bill: BillDto, reload: () => void): void { + const redraw = (): void => { + ctx.body.replaceChildren(); + drawBill(ctx, bill, reload); + }; + const bar = el("div", "b09s-bar"); + bar.append( + createButton({ label: L("B09_Sheet_Reload"), variant: "ghost", onClick: reload }), + levelPicker(bill, redraw), + el( + "span", + "", + `${L("B09_Sheet_BodyTotal")} ${won(bill.summary.body_total_krw)}${L("B09_Sheet_Won")}`, + ), + ); + if (bill.summary.unconfirmed_count > 0) + bar.append(unconfirmedBadge(bill.summary.unconfirmed_count)); + // 치수 없이 기본값으로 선 줄 — 빨간 테두리 줄로 서고 금액·합계엔 안 듦(브레인 판정 2026-09-14). + if (bill.summary.unpriced_count) + bar.append( + el( + "span", + "b09s-badge", + `${L("B09_Sheet_Unconfirmed")} ${bill.summary.unpriced_count}${L("B09_Sheet_Count")} — ${L("B09_Sheet_Unpriced")}`, + ), + ); + const rateToggle = el("button", "b09s-undo", L("B09_Sheet_Rate")); + rateToggle.type = "button"; + rateToggle.addEventListener("click", () => { + rateMode = !rateMode; + redraw(); + }); + bar.append(rateToggle); + ctx.body.append(bar); + if (rateMode && ctx.projectId) ctx.body.append(rateForm(ctx.projectId, selectedRates, reload)); + + const { wrap, tbody } = sheetTable( + sheetHead([ + L("B09_Sheet_Col_ItemNo"), + L("B09_Sheet_Col_Name"), + L("B09_Sheet_Col_Spec"), + L("B09_Sheet_Col_Quantity"), + L("B09_Sheet_Col_Unit"), + ]), + ); + drawRows(tbody, bill, ctx, redraw, reload); + const sum = el("tr", "is-sum"); + sum.append(el("td"), el("td", "", L("B09_Sheet_BodyTotal")), el("td"), el("td"), el("td")); + sum.append(...moneyCells(null, [bill.summary.body_total_krw, "", "", ""]), el("td")); + tbody.append(sum); + ctx.body.append(wrap); + + // 금액을 못 세운 줄 — 0 으로 안 때우고 이름째 보임(서버 `missing`). + if (bill.summary.missing.length > 0) { + const details = el("details"); + details.append( + el( + "summary", + "b09s-hint b09s-hint--warn", + `${L("B09_Sheet_Missing")} ${bill.summary.missing.length}${L("B09_Sheet_Count")}`, + ), + ); + for (const item of bill.summary.missing) { + details.append(hint(`${item.name} — ${item.reason}`)); + } + ctx.body.append(details); + } + for (const note of bill.summary.notes) ctx.body.append(hint(note)); +} + +export const billTab: B09Tab = { + key: "boq", + label: () => L("B09_Estimation_Tab_Boq"), + render(ctx) { + if (!ctx.projectId) { + ctx.body.append(hint(L("B09_Sheet_NoProject"))); + return; + } + const projectId = ctx.projectId; + const show = (force: boolean): void => { + ctx.body.replaceChildren(hint(L("B09_Sheet_Loading"))); + loadBill(projectId, force) + .then((bill) => { + ctx.body.replaceChildren(); + drawBill(ctx, bill, () => show(true)); + }) + .catch((error: Error) => { + ctx.body.replaceChildren(hint(`${L("B09_Sheet_LoadFailed")} ${error.message}`, true)); + showToast(L("B09_Sheet_LoadFailed"), "error"); + }); + }; + show(false); + }, +}; diff --git a/B09_Estimation/B09_Estimation_UI_Tab_Completion.ts b/B09_Estimation/B09_Estimation_UI_Tab_Completion.ts new file mode 100644 index 00000000..3fe2050f --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tab_Completion.ts @@ -0,0 +1,178 @@ +/* ============================================================================= + * B09_Estimation_UI_Tab_Completion.ts + * 준공 탭 — STmate 「준공조서(을/병)」의 「계약금액 | 준공금액」 두 열을 본뜸 (PLAN 12장 · 랩탑 메인). + * + * - 위 = 제잡비 줄 + 합계 줄(직접공사비 · 제잡비 계 · 공급가액 · 부가세 · 기성금액). + * - 아래 = 공종별 계약금액 | 준공금액. + * - ⚠ 준공 별도 계산 규칙은 미확인 — 서버(`/estimation/completion`)가 기성 마지막 회차 누계를 + * 옮기기만 함. 입력 칸·[저장] 없음(기성 탭에서 고침). + * ========================================================================== */ + +import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; +import { API_BASE_URL } from "@config/config_frontend"; +import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +interface CompletionRow { + item_no: string; + name: string; + spec: string | null; + unit: string | null; + is_group: boolean; + contract_amount_krw: string | null; + completion_amount_krw: string | null; +} + +interface CompletionLine { + key: string; + name: string; + total: boolean; + contract_krw: string; + completion_krw: string | null; + completion_pct: string | null; +} + +interface CompletionDto { + status: string; + message?: string; + rows: CompletionRow[]; + lines: CompletionLine[]; + from_round: number; + notes: string[]; + limit_note: string; +} + +const STYLE_ID = "b09-completion-styles"; +function injectStyles(): void { + if (document.getElementById(STYLE_ID)) return; + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = ` +.b09cp { display: flex; flex-direction: column; gap: 8px; height: 100%; min-height: 0; } +.b09cp__meta { font-size: 12px; color: var(--color-text-secondary); } +.b09cp__warn { font-size: 12px; color: var(--color-warning-text, #8a5a00); } +.b09cp__scroll { flex: 1; overflow: auto; min-height: 0; display: flex; flex-direction: column; gap: 12px; } +.b09cp__table { border-collapse: collapse; font-size: 12px; white-space: nowrap; } +.b09cp__table th, .b09cp__table td { border: 1px solid var(--color-border); padding: 2px 6px; } +.b09cp__table td.num { text-align: right; font-variant-numeric: tabular-nums; } +.b09cp__table tr.is-total td, .b09cp__table tr.is-group td { font-weight: 600; } +`; + document.head.append(style); +} + +function el( + tag: K, + className = "", + text = "", +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + if (className) node.className = className; + if (text) node.textContent = text; + return node; +} + +function won(value: string | null | undefined): string { + if (value === null || value === undefined || value === "") return ""; + const n = Number(value); + return Number.isFinite(n) ? n.toLocaleString("ko-KR") : value; +} + +async function fetchCompletion(projectId: string): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/completion`, + { credentials: "include" }, + ); + const body = (await response.json()) as CompletionDto; + if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`); + return body; +} + +function table(headers: string[]): HTMLTableElement { + const node = el("table", "b09cp__table"); + const head = el("tr"); + for (const label of headers) head.append(el("th", "", label)); + node.append(head); + return node; +} + +function draw(ctx: B09TabContext, data: CompletionDto): void { + const wrap = el("div", "b09cp"); + wrap.append(el("div", "b09cp__warn", `⚠ ${data.limit_note}`)); + for (const note of data.notes) wrap.append(el("div", "b09cp__warn", `⚠ ${note}`)); + wrap.append( + el( + "div", + "b09cp__meta", + data.from_round + ? `준공금액 = 기성 ${data.from_round}회(마지막 회차) 누계 — 고칠 곳은 기성 탭` + : "기성 탭에서 회차를 넣으면 그 누계가 준공금액으로 옮겨짐", + ), + ); + const scroll = el("div", "b09cp__scroll"); + + const summary = table(["명칭", "계약금액", "준공금액", "준공(%)"]); + for (const line of data.lines) { + const tr = el("tr", line.total ? "is-total" : ""); + tr.append( + el("td", "", line.name), + el("td", "num", won(line.contract_krw)), + el("td", "num", won(line.completion_krw)), + el("td", "num", line.completion_pct ? `${line.completion_pct}%` : ""), + ); + summary.append(tr); + } + const bill = table(["공종번호", "명칭", "규격", "단위", "계약금액", "준공금액"]); + for (const row of data.rows) { + const tr = el("tr", row.is_group ? "is-group" : ""); + tr.append( + el("td", "", row.item_no), + el("td", "", row.name), + el("td", "", row.spec ?? ""), + el("td", "", row.unit ?? ""), + el("td", "num", won(row.contract_amount_krw)), + el("td", "num", won(row.completion_amount_krw)), + ); + bill.append(tr); + } + const top = el("div"); + top.append(el("strong", "", "준공조서 — 계약금액 | 준공금액"), summary); + const bottom = el("div"); + bottom.append(el("strong", "", "공종별"), bill); + scroll.append(top, bottom); + wrap.append(scroll); + ctx.body.append(wrap); +} + +function render(ctx: B09TabContext): void { + injectStyles(); + if (!ctx.projectId) { + ctx.body.append(el("div", "b09cp__meta", "프로젝트를 고르세요")); + return; + } + ctx.body.replaceChildren( + el("div", "b09cp__meta", `${L("B09_Estimation_Tab_Completion")} 계산 중…`), + ); + fetchCompletion(ctx.projectId) + .then((data) => { + ctx.body.replaceChildren(); + draw(ctx, data); + }) + .catch((error: unknown) => { + ctx.body.replaceChildren( + el( + "div", + "b09cp__warn", + `준공을 세우지 못함 — ${error instanceof Error ? error.message : ""}`, + ), + ); + }); +} + +export const completionTab: B09Tab = { + key: "completion", + label: () => L("B09_Estimation_Tab_Completion"), + render, +}; diff --git a/B09_Estimation/B09_Estimation_UI_Tab_Contract.ts b/B09_Estimation/B09_Estimation_UI_Tab_Contract.ts new file mode 100644 index 00000000..ef13d8c1 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tab_Contract.ts @@ -0,0 +1,367 @@ +/* ============================================================================= + * B09_Estimation_UI_Tab_Contract.ts + * 계약내역 탭 — STmate 「당초설계 → 계약내역 변환등록」(wM_Mk_Cont)을 본뜸 (PLAN 12장 · 랩탑 메인). + * + * - 좌측 = 단가 적용율 【 노 】【 재 】【 경 】 % · 적용 옵션 여섯(화면 표기 그대로) · [저장]. + * - 본문 = 공종번호 · 명칭 · 규격 · 금액(설계) · 계약 단가·금액 · **적용제외** · 계약단가 코드. + * - ⚠ 값은 서버(`/estimation/contract`)가 설계 내역을 복사해 셈 — 설계는 안 바뀜. + * - ⚠ 계약 표본이 없어 「구조가 선다」까지만 확인된 화면 — 머리에 그 한계를 적음. + * ========================================================================== */ + +import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, showToast } from "@ui/ui_template_elements"; +import { API_BASE_URL } from "@config/config_frontend"; +import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +interface ContractRow { + item_no: string; + name: string; + spec: string; + unit: string; + quantity: string | null; + is_group: boolean; + in_bill: boolean; + amount_krw: string | null; + contract_unit_price_krw?: string; + contract_amount_krw?: string; + contract_code?: string; + contract_excluded?: boolean; + contract_note?: string; +} + +interface Money { + material_krw: string; + labor_krw: string; + expense_krw: string; + total_krw: string; +} + +/** 계약 호표 한 장 — 설계 본표(`detail_of`)와 같은 꼴 + 계약 코드(W-). */ +interface ContractSheet { + code: string; + design_code: string; + name: string; + spec: string; + unit: string; + material: string; + labor: string; + expense: string; + total: string; + rows: { + name: string; + spec: string; + unit?: string; + quantity: string; + material: string; + labor: string; + expense: string; + total: string; + }[]; +} + +interface ContractDto { + status: string; + message?: string; + rows: ContractRow[]; + unit_price_sheets: ContractSheet[]; + totals: { design: Money; contract: Money; ratio_pct: Record }; + options_not_used: Record; + settings: Record; + fields: { + rates: { key: string; label: string }[]; + options: { key: string; label: string }[]; + }; + bill_missing_count: number; + limit_note: string; +} + +const STYLE_ID = "b09-contract-styles"; +function injectStyles(): void { + if (document.getElementById(STYLE_ID)) return; + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = ` +.b09ct { display: flex; flex-direction: column; gap: 8px; height: 100%; min-height: 0; } +.b09ct__meta { font-size: 12px; color: var(--color-text-secondary); } +.b09ct__warn { font-size: 12px; color: var(--color-warning-text, #8a5a00); } +.b09ct__scroll { flex: 1; overflow: auto; min-height: 0; } +.b09ct__table { border-collapse: collapse; font-size: 12px; white-space: nowrap; } +.b09ct__table th, .b09ct__table td { border: 1px solid var(--color-border); padding: 2px 6px; } +.b09ct__table td.num { text-align: right; font-variant-numeric: tabular-nums; } +.b09ct__table tr.is-group td { font-weight: 600; } +.b09ct__table tr.is-excluded td { color: var(--color-text-secondary); } +.b09ct__panel { display: flex; flex-direction: column; gap: 6px; font-size: 12px; } +.b09ct__rates { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; } +.b09ct__rates input { width: 4.5em; text-align: right; } +.b09ct__option { display: flex; gap: 4px; align-items: flex-start; } +.b09ct__totals { font-size: 12px; font-variant-numeric: tabular-nums; } +.b09ct__sheets { display: flex; flex-direction: column; gap: 4px; margin-top: 12px; font-size: 12px; } +.b09ct__sheets summary { cursor: pointer; } +`; + document.head.append(style); +} + +function el( + tag: K, + className = "", + text = "", +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + if (className) node.className = className; + if (text) node.textContent = text; + return node; +} + +function won(value: string | null | undefined): string { + if (value === null || value === undefined || value === "") return ""; + const n = Number(value); + return Number.isFinite(n) ? n.toLocaleString("ko-KR") : value; +} + +/** 프로젝트별 입력 캐시 — [저장] 전 값(지침 5장 · 자동저장 없음). */ +const drafts = new Map>(); + +async function fetchContract(projectId: string): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/contract`, + { credentials: "include" }, + ); + const body = (await response.json()) as ContractDto; + if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`); + return body; +} + +async function saveContract( + projectId: string, + values: Record, +): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/contract`, + { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(values), + }, + ); + const body = (await response.json()) as { message?: string }; + if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`); +} + +function drawPanel(ctx: B09TabContext, data: ContractDto, reload: () => void): void { + const projectId = ctx.projectId as string; + const draft = drafts.get(projectId) ?? { ...data.settings }; + drafts.set(projectId, draft); + const box = el("div", "b09ct__panel"); + box.append(el("strong", "", "단가 적용율")); + const rates = el("div", "b09ct__rates"); + for (const field of data.fields.rates) { + const label = el("label", "b09ct__rates"); + label.append(el("span", "", `【 ${field.label} 】`)); + const input = el("input"); + input.type = "number"; + input.min = "0"; + input.step = "0.001"; + input.value = String(draft[field.key] ?? "100"); + input.addEventListener("input", () => (draft[field.key] = input.value)); + label.append(input, el("span", "", "%")); + rates.append(label); + } + box.append(rates); + for (const option of data.fields.options) { + const row = el("label", "b09ct__option"); + const check = el("input"); + check.type = "checkbox"; + check.checked = Boolean(draft[option.key]); + check.addEventListener("change", () => (draft[option.key] = check.checked)); + row.append(check, el("span", "", option.label)); + box.append(row); + const unused = data.options_not_used[option.key]; + if (unused) box.append(el("span", "b09ct__warn", `⚠ ${unused}`)); + } + box.append( + createButton({ + label: "저장", + onClick: async () => { + try { + await saveContract(projectId, draft); + drafts.delete(projectId); + showToast("계약 조건 저장 — 설계 내역은 그대로", "success"); + reload(); + } catch (error) { + showToast(error instanceof Error ? error.message : "저장 못 함", "error"); + } + }, + }), + ); + const t = data.totals; + const totals = el("div", "b09ct__totals"); + for (const [label, part] of [ + ["재료비", "material"], + ["노무비", "labor"], + ["경비", "expense"], + ] as const) { + totals.append( + el( + "div", + "", + `${label} ${won(t.design[`${part}_krw`])} → ${won(t.contract[`${part}_krw`])}` + + (t.ratio_pct[part] ? ` (${t.ratio_pct[part]}%)` : ""), + ), + ); + } + totals.append( + el("div", "", `직접공사비 ${won(t.design.total_krw)} → ${won(t.contract.total_krw)}`), + ); + box.append(totals); + ctx.panel.append(box); +} + +function drawBody(ctx: B09TabContext, data: ContractDto): void { + const projectId = ctx.projectId as string; + const draft = drafts.get(projectId) ?? { ...data.settings }; + drafts.set(projectId, draft); + const excluded = new Set((draft.excluded as string[] | undefined) ?? []); + const wrap = el("div", "b09ct"); + wrap.append(el("div", "b09ct__warn", `⚠ ${data.limit_note}`)); + if (data.bill_missing_count) { + wrap.append( + el( + "div", + "b09ct__warn", + `⚠ 설계 내역 ${L("B09_Sheet_Missing")} ${data.bill_missing_count}${L("B09_Sheet_Count")} — 계약단가도 못 섬`, + ), + ); + } + wrap.append( + el("div", "b09ct__meta", "적용율을 제외할(ex 관급자재대..) 공정을 선택 — [저장]하면 반영"), + ); + const scroll = el("div", "b09ct__scroll"); + const table = el("table", "b09ct__table"); + const head = el("tr"); + for (const label of [ + "공종번호", + "명칭", + "규격", + "단위", + "수량", + "금액(설계)", + "계약 단가", + "계약 금액", + "적용제외", + "계약단가 코드", + "비고", + ]) { + head.append(el("th", "", label)); + } + table.append(head); + for (const row of data.rows) { + const tr = el("tr", row.is_group ? "is-group" : row.contract_excluded ? "is-excluded" : ""); + tr.append( + el("td", "", row.item_no), + el("td", "", row.name), + el("td", "", row.spec ?? ""), + el("td", "", row.unit ?? ""), + el("td", "num", row.quantity ?? ""), + el("td", "num", won(row.amount_krw)), + el("td", "num", won(row.contract_unit_price_krw)), + el("td", "num", won(row.contract_amount_krw)), + ); + const cell = el("td"); + if (!row.is_group && row.in_bill) { + const check = el("input"); + check.type = "checkbox"; + check.checked = excluded.has(row.item_no); + check.addEventListener("change", () => { + if (check.checked) excluded.add(row.item_no); + else excluded.delete(row.item_no); + draft.excluded = [...excluded]; + }); + cell.append(check); + } + tr.append(cell, el("td", "", row.contract_code ?? ""), el("td", "", row.contract_note ?? "")); + table.append(tr); + } + scroll.append(table); + if (data.unit_price_sheets.length) scroll.append(drawSheets(data.unit_price_sheets)); + wrap.append(scroll); + ctx.body.append(wrap); +} + +/** 「적용율 적용된 일위대가/산출근거」 — 설계 호표는 그대로, 계약 호표(W-)만 따로 펼침. */ +function drawSheets(sheets: ContractSheet[]): HTMLElement { + const box = el("div", "b09ct__sheets"); + box.append(el("strong", "", `계약 일위대가·산출근거 ${sheets.length}장 — 설계 호표는 그대로`)); + for (const sheet of sheets) { + const details = el("details"); + details.append( + el( + "summary", + "", + `${sheet.code} ${sheet.name} ${sheet.spec} (설계 ${sheet.design_code}) — ` + + `재 ${won(sheet.material)} · 노 ${won(sheet.labor)} · 경 ${won(sheet.expense)} · 계 ${won(sheet.total)}`, + ), + ); + const table = el("table", "b09ct__table"); + const head = el("tr"); + for (const label of ["명칭", "규격", "단위", "수량", "재료비", "노무비", "경비", "합계"]) { + head.append(el("th", "", label)); + } + table.append(head); + for (const row of sheet.rows) { + const tr = el("tr"); + tr.append( + el("td", "", row.name), + el("td", "", row.spec ?? ""), + el("td", "", row.unit ?? ""), + el("td", "num", row.quantity), + el("td", "num", won(row.material)), + el("td", "num", won(row.labor)), + el("td", "num", won(row.expense)), + el("td", "num", won(row.total)), + ); + table.append(tr); + } + details.append(table); + box.append(details); + } + return box; +} + +function render(ctx: B09TabContext): void { + injectStyles(); + if (!ctx.projectId) { + ctx.body.append(el("div", "b09ct__meta", "프로젝트를 고르세요")); + return; + } + const load = (): void => { + ctx.body.replaceChildren(el("div", "b09ct__meta", "계약내역 계산 중…")); + ctx.panel.replaceChildren(); + fetchContract(ctx.projectId as string) + .then((data) => { + ctx.body.replaceChildren(); + drawPanel(ctx, data, load); + drawBody(ctx, data); + }) + .catch((error: unknown) => { + ctx.body.replaceChildren( + el( + "div", + "b09ct__warn", + `계약내역을 세우지 못함 — ${error instanceof Error ? error.message : ""}`, + ), + ); + }); + }; + load(); +} + +export const contractTab: B09Tab = { + key: "contract", + label: () => L("B09_Estimation_Tab_Contract"), + render, +}; diff --git a/B09_Estimation/B09_Estimation_UI_Tab_CostSheet.ts b/B09_Estimation/B09_Estimation_UI_Tab_CostSheet.ts new file mode 100644 index 00000000..03675497 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tab_CostSheet.ts @@ -0,0 +1,388 @@ +/* ============================================================================= + * B09_Estimation_UI_Tab_CostSheet.ts + * 원가계산서 탭 — STmate 「제잡비 계산」(wM_KanJub) 일반형식을 본뜸 (PLAN 12장 · 랩탑 메인). + * + * - 좌측 = 기준 입력판: 번호·제목은 DFM `TWM_KANJUB` 그대로. 엔진이 받는 칸만 엶. + * - 본문 = 서식(점선 라벨 + 오른쪽 금액) · 아래 형식 탭(일반형식만 켜짐) · 상태줄(직재·직노·산경·이윤). + * - ⚠ 값은 서버(`/estimation/cost-sheet`)가 냄 — 여기는 그리기·입력만(지침 5장). + * - 입력은 캐시(모듈 안)에만 쌓이고 [저장]에서 정본으로 감 · 자동저장 없음. + * ========================================================================== */ + +import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, showToast } from "@ui/ui_template_elements"; +import { API_BASE_URL } from "@config/config_frontend"; +import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types"; +import { injectSheetStyles, unconfirmedBadge } from "./B09_Estimation_UI_Sheet"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +interface SheetRow { + key: string; + mark: string; + label: string; + level: number; + formula: string; + rate_percent: string | null; + /** 형식 서식만 선 줄(수공·실적)은 null — 0 원으로 안 채움. */ + amount_krw: string | null; + blocked?: boolean; + note: string; + total: boolean; +} + +interface FieldOption { + value: string; + label: string; +} + +interface CostSheetDto { + status: string; + message?: string; + rows: SheetRow[]; + status_line: Record; + settings: Record; + fields: { + work_types: { key: string; label: string }[]; + /** 10·11·12 — 선택지는 엔진 규칙에서(요율 데이터 아님). */ + choices: { key: string; label: string }[]; + amounts: { key: string; label: string }[]; + options: Record; + /** 칸 밑 안내 — 기본값의 까닭 · 실무가 어떻게 쓰는지. */ + hints: Record; + }; + waste_note: string; + form_key: string; + form_labels: Record; + form_note?: string; + bill_missing_count: number; + bill_unconfirmed_count: number; + rate_version: { dataset_id: string; effective_date: string }; + notes: string[]; +} + +/** DFM 기준 입력판 첫 칸 「제잡비계산형식」 선택지 그대로. 지금은 「일반 형식」만 엶. */ +const FORMS = [ + "일반 형식", + "토지개발공사 형식", + "인천상수도 형식", + "수자원공사 형식", + "지역난방공사 형식", + "고용개선지원(서울)", +]; +/** DFM 본문 아래 탭 이름 그대로. */ +const FORM_TABS = ["general", "sugong", "actual_general", "actual_sugong"]; +/** 프로젝트별 고른 형식 탭 — 일반형식만 금액이 섬. */ +const formOf = new Map(); + +const STYLE_ID = "b09-cost-sheet-styles"; +function injectStyles(): void { + if (document.getElementById(STYLE_ID)) return; + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = ` +.b09cs { display: flex; flex-direction: column; gap: 8px; height: 100%; min-height: 0; } +.b09cs__head { display: flex; flex-wrap: wrap; gap: 8px; align-items: baseline; } +.b09cs__title { font-weight: 600; } +.b09cs__meta { font-size: 12px; color: var(--color-text-secondary); } +.b09cs__warn { font-size: 12px; color: var(--color-warning-text, #8a5a00); } +.b09cs__sheet { flex: 1; overflow: auto; min-height: 0; border: 1px solid var(--color-border); padding: 8px 12px; } +.b09cs__line { display: flex; align-items: baseline; gap: 6px; font-size: 13px; line-height: 1.9; } +.b09cs__mark { flex: 0 0 2.4em; text-align: right; } +.b09cs__label { flex: 1; min-width: 0; display: flex; gap: 4px; overflow: hidden; white-space: nowrap; } +.b09cs__label::after { content: ""; flex: 1; border-bottom: 1px dotted var(--color-border); margin-bottom: 4px; } +.b09cs__formula { color: var(--color-text-secondary); font-size: 12px; overflow: hidden; text-overflow: ellipsis; } +.b09cs__amount { flex: 0 0 11em; text-align: right; font-variant-numeric: tabular-nums; } +.b09cs__line--total { font-weight: 600; } +.b09cs__line--total .b09cs__amount { flex-basis: 12.5em; } +.b09cs__tabs { display: flex; gap: 2px; border-top: 1px solid var(--color-border); padding-top: 4px; } +.b09cs__tab { font-size: 12px; padding: 2px 10px; border: 1px solid var(--color-border); border-top: none; background: none; } +.b09cs__tab[aria-selected="true"] { font-weight: 600; background: var(--color-surface, #fff); } +.b09cs__status { display: flex; gap: 16px; font-size: 12px; font-variant-numeric: tabular-nums; } +.b09cs__panel { display: flex; flex-direction: column; gap: 6px; } +.b09cs__field { display: flex; flex-direction: column; gap: 2px; font-size: 12px; } +.b09cs__field select, .b09cs__field input { width: 100%; } +.b09cs__group { font-size: 12px; font-weight: 600; margin-top: 6px; } +.b09cs__buttons { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; } +`; + document.head.append(style); +} + +function won(value: string): string { + const n = Number(value); + return Number.isFinite(n) ? n.toLocaleString("ko-KR") : value; +} + +function el( + tag: K, + className = "", + text = "", +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + if (className) node.className = className; + if (text) node.textContent = text; + return node; +} + +/** 프로젝트별 입력 캐시 — 탭을 다시 골라도 [저장] 전 값이 남음. */ +const drafts = new Map>(); + +async function fetchSheet(projectId: string, form: string): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/cost-sheet?form=${encodeURIComponent(form)}`, + { credentials: "include" }, + ); + const body = (await response.json()) as CostSheetDto; + if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`); + return body; +} + +async function saveSheet(projectId: string, values: Record): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/cost-sheet`, + { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(values), + }, + ); + if (!response.ok) throw new Error(`HTTP ${response.status}`); +} + +function selectField( + label: string, + value: string, + options: { value: string; label: string; disabled?: boolean }[], + onChange: (value: string) => void, +): HTMLElement { + const wrap = el("label", "b09cs__field"); + wrap.append(el("span", "", label)); + const select = el("select"); + for (const option of options) { + const node = el("option", "", option.label); + node.value = option.value; + node.disabled = Boolean(option.disabled); + select.append(node); + } + select.value = value; + select.addEventListener("change", () => onChange(select.value)); + wrap.append(select); + return wrap; +} + +function numberField(label: string, value: string, onInput: (value: string) => void): HTMLElement { + const wrap = el("label", "b09cs__field"); + wrap.append(el("span", "", label)); + const input = el("input"); + input.type = "number"; + input.min = "0"; + input.value = value; + input.addEventListener("input", () => onInput(input.value)); + wrap.append(input); + return wrap; +} + +function drawPanel(ctx: B09TabContext, sheet: CostSheetDto, reload: () => void): void { + const projectId = ctx.projectId as string; + const draft = + drafts.get(projectId) ?? + Object.fromEntries(Object.entries(sheet.settings).map(([k, v]) => [k, String(v)])); + drafts.set(projectId, draft); + const box = el("div", "b09cs__panel"); + box.append(el("div", "b09cs__group", "기준 입력")); + box.append( + selectField( + "제잡비계산형식 :", + FORMS[0], + FORMS.map((name, i) => ({ + value: name, + label: i ? `${name} (준비 중)` : name, + disabled: i > 0, + })), + () => undefined, + ), + ); + const workType = (key: string) => + [...sheet.fields.work_types, ...sheet.fields.choices].find((f) => f.key === key); + const typeSelect = (key: string) => { + const field = workType(key); + if (!field) return; + box.append( + selectField(field.label + " :", draft[key] ?? "", sheet.fields.options[key] ?? [], (v) => { + draft[key] = v; + }), + ); + const hint = sheet.fields.hints?.[key]; + if (hint) box.append(el("div", "b09cs__meta", hint)); + }; + typeSelect("work_type_indirect_labor"); + box.append( + numberField("3.공사의기간(일) :", draft.duration_days ?? "", (v) => { + draft.duration_days = v; + }), + ); + typeSelect("employment_insurance_grade"); + typeSelect("retirement_mutual_aid_mode"); + for (const field of sheet.fields.amounts.slice(0, 2)) { + box.append( + numberField(field.label + " :", draft[field.key] ?? "", (v) => (draft[field.key] = v)), + ); + } + typeSelect("work_type_safety"); + typeSelect("environment_work_type"); + typeSelect("equipment_guarantee_work_type"); + // 10·11·12 — DFM 번호 차례. 절사는 「안 함」이 기본(고른 때만 이윤 자동보정). + typeSelect("overhead_class"); + typeSelect("cut_basis"); + typeSelect("cut_unit_krw"); + typeSelect("vat_mode"); + // 13~16 — 사급비 위치·적용기준은 칸만 받음(계산에 안 씀 · 까닭은 칸 밑 안내). + typeSelect("private_material_position"); + typeSelect("bid_method"); + typeSelect("subcontract_guarantee"); + typeSelect("performance_guarantee_mode"); + typeSelect("contract_law_basis"); + typeSelect("waste_placement"); + box.append(el("div", "b09cs__group", "계산/인쇄 설정")); + for (const field of sheet.fields.amounts.slice(2)) { + box.append( + numberField(field.label + " :", draft[field.key] ?? "", (v) => (draft[field.key] = v)), + ); + } + + const buttons = el("div", "b09cs__buttons"); + buttons.append( + createButton({ + label: "제비율표", + variant: "ghost", + onClick: () => ctx.open("rate_table"), + }), + createButton({ + label: "다시 계산", + variant: "ghost", + onClick: () => { + drafts.delete(projectId); + reload(); + }, + }), + createButton({ + label: "저장", + onClick: async () => { + try { + await saveSheet(projectId, draft); + drafts.delete(projectId); + showToast("원가계산서 기준 저장", "success"); + reload(); + } catch (error) { + showToast(error instanceof Error ? error.message : "저장 못 함", "error"); + } + }, + }), + ); + box.append(buttons); + ctx.panel.append(box); +} + +function drawBody(ctx: B09TabContext, sheet: CostSheetDto, reload: () => void): void { + const wrap = el("div", "b09cs"); + const head = el("div", "b09cs__head"); + head.append( + el("span", "b09cs__title", `제잡비 계산 — ${sheet.form_labels[sheet.form_key] ?? ""}`), + el( + "span", + "b09cs__meta", + `요율 판 ${sheet.rate_version.effective_date} · 직접비는 설계내역서 합계`, + ), + ); + // 표현은 내역서·구조물도와 한 벌(「금액을 못 세운 줄 N건」 · 「미확정 N건」 배지). + if (sheet.bill_missing_count) { + head.append( + el( + "span", + "b09cs__warn", + `⚠ 내역서 ${L("B09_Sheet_Missing")} ${sheet.bill_missing_count}${L("B09_Sheet_Count")} — 직접비가 덜 섬`, + ), + ); + } + if (sheet.bill_unconfirmed_count) head.append(unconfirmedBadge(sheet.bill_unconfirmed_count)); + if (sheet.waste_note) head.append(el("span", "b09cs__meta", `폐기물처리비: ${sheet.waste_note}`)); + if (sheet.form_note) head.append(el("span", "b09cs__warn", `⚠ ${sheet.form_note}`)); + wrap.append(head); + + const body = el("div", "b09cs__sheet"); + for (const row of sheet.rows) { + const line = el("div", `b09cs__line${row.total ? " b09cs__line--total" : ""}`); + line.style.paddingLeft = `${row.level * 1.2}em`; + line.append(el("span", "b09cs__mark", row.mark)); + const label = el("span", "b09cs__label"); + label.append(el("span", "", row.label)); + if (row.formula) label.append(el("span", "b09cs__formula", `<${row.formula}>`)); + label.title = [row.formula, row.note].filter(Boolean).join(" · "); + // 금액 없는 줄 — 「-」 + 막힌 까닭(요율표 미확보 · 확인 대기). 0 원으로 안 보임. + if (row.amount_krw === null && row.blocked) label.append(el("span", "b09cs__warn", row.note)); + line.append( + label, + el("span", "b09cs__amount", row.amount_krw === null ? "-" : won(row.amount_krw)), + ); + body.append(line); + } + wrap.append(body); + + const tabs = el("div", "b09cs__tabs"); + for (const key of FORM_TABS) { + const tab = el("button", "b09cs__tab", sheet.form_labels[key] ?? key); + tab.setAttribute("aria-selected", String(key === sheet.form_key)); + if (key !== "general") tab.title = "서식 차례만 — 요율표 미확보로 금액 안 섬"; + tab.addEventListener("click", () => { + formOf.set(ctx.projectId as string, key); + reload(); + }); + tabs.append(tab); + } + wrap.append(tabs); + + const status = el("div", "b09cs__status"); + for (const [name, value] of Object.entries(sheet.status_line)) { + status.append(el("span", "", `${name}: ${won(value)}`)); + } + wrap.append(status); + for (const note of sheet.notes) wrap.append(el("div", "b09cs__meta", note)); + ctx.body.append(wrap); +} + +function render(ctx: B09TabContext): void { + injectStyles(); + injectSheetStyles(); + if (!ctx.projectId) { + ctx.body.append(el("div", "b09cs__meta", "프로젝트를 고르세요")); + return; + } + const load = (): void => { + ctx.body.replaceChildren(el("div", "b09cs__meta", "원가계산서 계산 중…")); + ctx.panel.replaceChildren(); + fetchSheet(ctx.projectId as string, formOf.get(ctx.projectId as string) ?? "general") + .then((sheet) => { + ctx.body.replaceChildren(); + drawPanel(ctx, sheet, load); + drawBody(ctx, sheet, load); + }) + .catch((error: unknown) => { + ctx.body.replaceChildren( + el( + "div", + "b09cs__warn", + `원가계산서를 세우지 못함 — ${error instanceof Error ? error.message : ""}`, + ), + ); + }); + }; + load(); +} + +export const costSheetTab: B09Tab = { + key: "cost_sheet", + label: () => L("B09_Estimation_Tab_CostSheet"), + render, +}; diff --git a/B09_Estimation/B09_Estimation_UI_Tab_DesignDoc.ts b/B09_Estimation/B09_Estimation_UI_Tab_DesignDoc.ts new file mode 100644 index 00000000..c2668719 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tab_DesignDoc.ts @@ -0,0 +1,82 @@ +/* ============================================================================= + * B09_Estimation_UI_Tab_DesignDoc.ts + * B09 설계서 구성 탭 — 법이 정한 목차와 우리가 내는 것을 맞대 봄(별표2 (5)(가)) + * + * - 옛 `B09_Estimation_UI_BaseData.ts` 의 설계서 구성표를 그대로 옮김(PLAN 12장 옛 탭 옮기기). + * - ⚠ 「없음」과 「우리 몫 아님」을 갈라 보임 — 갈라야 다음에 할 일이 달라짐. + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import type { B09Tab } from "./B09_Estimation_UI_Shell_Types"; +import { L, hint } from "./B09_Estimation_UI_Sheet"; +import { head, infoTable, note } from "./B09_Estimation_UI_Table"; + +interface DesignDocDto { + law: string; + summary: string; + items: Array<{ + order: number; + name: string; + status: string; + owner: string; + where: string; + note: string; + }>; + notes: string[]; +} + +let cached: DesignDocDto | null = null; + +function draw(body: HTMLElement, data: DesignDocDto): void { + body.append(head(`설계서 구성 (법이 정한 ${data.items.length})`)); + body.append(note(data.summary)); + body.append(note(data.law)); + body.append( + infoTable( + ["차례", "이 름", "상 태", "누가 만드나", "어디서 나오나", "비 고"], + data.items.map((row) => [ + String(row.order), + row.name, + row.status, + row.owner, + row.where || "—", + row.note, + ]), + [0, 1, 2, 3, 4, 5], + ), + ); + for (const line of data.notes) body.append(note(line)); +} + +export const designDocTab: B09Tab = { + key: "design_doc", + label: () => L("B09_Estimation_Tab_DesignDoc"), + render(ctx) { + if (!ctx.projectId) { + ctx.body.append(hint(L("B09_Sheet_NoProject"))); + return; + } + // 프로젝트 값이 아니라 **우리가 무엇을 내는가**의 표 — 한 번 받으면 그대로 씀. + if (cached) { + draw(ctx.body, cached); + return; + } + ctx.body.append(hint(L("B09_Sheet_Loading"))); + fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(ctx.projectId)}/estimation/design-doc-index`, + { credentials: "include" }, + ) + .then((response) => { + if (!response.ok) throw new Error(String(response.status)); + return response.json() as Promise; + }) + .then((data) => { + cached = data; + ctx.body.replaceChildren(); + draw(ctx.body, data); + }) + .catch((error: Error) => { + ctx.body.replaceChildren(hint(`${L("B09_Sheet_LoadFailed")} ${error.message}`, true)); + }); + }, +}; diff --git a/B09_Estimation/B09_Estimation_UI_Tab_Execution.ts b/B09_Estimation/B09_Estimation_UI_Tab_Execution.ts new file mode 100644 index 00000000..c6526e51 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tab_Execution.ts @@ -0,0 +1,407 @@ +/* ============================================================================= + * B09_Estimation_UI_Tab_Execution.ts + * 실행예산 탭 — STmate 「시간/단위당 중기실행단가 계산 및 입력」(wM_Boq_ExecX)을 본뜸 (PLAN 12장 · 랩탑 메인). + * + * - 본문 위 = 중기 실행단가 표 — 최초단가(노·재·경) · 1단위당 중기사용료 · 1단위 = 시간 · + * 절사 · 차액 보정 → 실행단가. + * - 본문 아래 = 설계 내역 줄 옆에 **실행수량** 칸 · 실행 단가 · 실행 금액. + * - ⚠ 값은 서버(`/estimation/execution`)가 설계 단가표를 복사해 셈 — 설계·계약은 안 바뀜. + * - ⚠ 실행예산 표본이 없어 「구조가 선다」까지만 확인된 화면 — 머리에 그 한계를 적음. + * ========================================================================== */ + +import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, showToast } from "@ui/ui_template_elements"; +import { API_BASE_URL } from "@config/config_frontend"; +import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +interface Money { + material_krw: string; + labor_krw: string; + expense_krw: string; + total_krw: string; +} + +interface MachineEntry { + unit_price_krw: string; + hours_per_unit: string; + cut_unit_krw: string; + correction: string; +} + +interface MachineRow { + code: string; + name: string; + spec: string; + design?: Money; + execution?: Money; + note?: string; +} + +interface ExecutionRow { + item_no: string; + name: string; + spec: string; + unit: string; + quantity: string | null; + is_group: boolean; + in_bill: boolean; + amount_krw: string | null; + execution_quantity?: string; + execution_unit_price_krw?: string; + execution_amount_krw?: string; + execution_note?: string; +} + +interface Settings { + machines: Record; + quantities: Record; +} + +interface ExecutionDto { + status: string; + message?: string; + rows: ExecutionRow[]; + machines: MachineRow[]; + totals: { design: Money; execution: Money }; + settings: Settings; + fields: { cut_units: string[]; corrections: { key: string; label: string; hint: string }[] }; + bill_missing_count: number; + limit_note: string; +} + +const STYLE_ID = "b09-execution-styles"; +function injectStyles(): void { + if (document.getElementById(STYLE_ID)) return; + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = ` +.b09ex { display: flex; flex-direction: column; gap: 8px; height: 100%; min-height: 0; } +.b09ex__meta { font-size: 12px; color: var(--color-text-secondary); } +.b09ex__warn { font-size: 12px; color: var(--color-warning-text, #8a5a00); } +.b09ex__scroll { flex: 1; overflow: auto; min-height: 0; display: flex; flex-direction: column; gap: 12px; } +.b09ex__table { border-collapse: collapse; font-size: 12px; white-space: nowrap; } +.b09ex__table th, .b09ex__table td { border: 1px solid var(--color-border); padding: 2px 6px; } +.b09ex__table td.num { text-align: right; font-variant-numeric: tabular-nums; } +.b09ex__table tr.is-group td { font-weight: 600; } +.b09ex__table input { width: 7em; text-align: right; } +.b09ex__table input.is-changed { font-weight: 600; } +.b09ex__panel { display: flex; flex-direction: column; gap: 6px; font-size: 12px; font-variant-numeric: tabular-nums; } +`; + document.head.append(style); +} + +function el( + tag: K, + className = "", + text = "", +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + if (className) node.className = className; + if (text) node.textContent = text; + return node; +} + +function won(value: string | null | undefined): string { + if (value === null || value === undefined || value === "") return ""; + const n = Number(value); + return Number.isFinite(n) ? n.toLocaleString("ko-KR") : value; +} + +/** 프로젝트별 입력 캐시 — [저장] 전 값(지침 5장 · 자동저장 없음). */ +const drafts = new Map(); + +function draftOf(projectId: string, data: ExecutionDto): Settings { + let draft = drafts.get(projectId); + if (!draft) { + draft = { + machines: structuredClone(data.settings.machines), + quantities: { ...data.settings.quantities }, + }; + drafts.set(projectId, draft); + } + return draft; +} + +function endpoint(projectId: string): string { + return `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/execution`; +} + +async function fetchExecution(projectId: string): Promise { + const response = await fetch(endpoint(projectId), { credentials: "include" }); + const body = (await response.json()) as ExecutionDto; + if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`); + return body; +} + +async function saveExecution(projectId: string, values: Settings): Promise { + const response = await fetch(endpoint(projectId), { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(values), + }); + const body = (await response.json()) as { message?: string }; + if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`); +} + +function drawPanel(ctx: B09TabContext, data: ExecutionDto, reload: () => void): void { + const projectId = ctx.projectId as string; + const box = el("div", "b09ex__panel"); + box.append( + createButton({ + label: "저장", + onClick: async () => { + try { + await saveExecution(projectId, draftOf(projectId, data)); + drafts.delete(projectId); + showToast("실행예산 저장 — 설계·계약 내역은 그대로", "success"); + reload(); + } catch (error) { + showToast(error instanceof Error ? error.message : "저장 못 함", "error"); + } + }, + }), + ); + const t = data.totals; + for (const [label, part] of [ + ["재료비", "material"], + ["노무비", "labor"], + ["경비", "expense"], + ["직접공사비", "total"], + ] as const) { + box.append( + el( + "div", + "", + `${label} ${won(t.design[`${part}_krw`])} → ${won(t.execution[`${part}_krw`])}`, + ), + ); + } + ctx.panel.append(box); +} + +function input(value: string, onInput: (value: string) => void): HTMLInputElement { + const node = el("input"); + node.type = "number"; + node.min = "0"; + node.value = value; + node.addEventListener("input", () => onInput(node.value)); + return node; +} + +function select( + options: { key: string; label: string; hint?: string }[], + value: string, + onChange: (value: string) => void, +): HTMLSelectElement { + const node = el("select"); + for (const option of options) { + const item = el("option", "", option.label); + item.value = option.key; + if (option.hint) item.title = option.hint; + node.append(item); + } + node.value = value; + node.addEventListener("change", () => onChange(node.value)); + return node; +} + +/** 중기 실행단가 표 — 사용료가 빈 중기는 설계 사용료 그대로. */ +function drawMachines(data: ExecutionDto, draft: Settings): HTMLElement { + const box = el("div"); + box.append(el("strong", "", `시간/단위당 중기실행단가 — 중기 ${data.machines.length}종`)); + // 차액 보정을 고를 때 무엇이 다른지 — 「기본보정」은 뜻 확인 대기(안 몰고 남김). + box.append( + el( + "div", + "b09ex__meta", + data.fields.corrections.map((option) => `${option.label}: ${option.hint}`).join(" · "), + ), + ); + const table = el("table", "b09ex__table"); + const head = el("tr"); + for (const label of [ + "코드", + "명칭", + "규격", + "최초 노무비", + "최초 재료비", + "최초 경비", + "최초 계", + "1단위당 중기사용료(원)", + "1단위 = 시간", + "절사", + "차액 보정", + "실행 노무비", + "실행 재료비", + "실행 경비", + "실행 계", + "비고", + ]) { + head.append(el("th", "", label)); + } + table.append(head); + const cuts = data.fields.cut_units.map((unit) => ({ + key: unit, + label: `${won(unit)}원 미만절사`, + })); + for (const machine of data.machines) { + const entry = (): MachineEntry => + (draft.machines[machine.code] ??= { + unit_price_krw: "", + hours_per_unit: "", + cut_unit_krw: "1", + correction: "basic", + }); + const saved = draft.machines[machine.code]; + const tr = el("tr"); + const d = machine.design; + const x = machine.execution; + tr.append( + el("td", "", machine.code), + el("td", "", machine.name), + el("td", "", machine.spec ?? ""), + el("td", "num", won(d?.labor_krw)), + el("td", "num", won(d?.material_krw)), + el("td", "num", won(d?.expense_krw)), + el("td", "num", won(d?.total_krw)), + ); + const cells: HTMLElement[] = [ + input(saved?.unit_price_krw ?? "", (v) => (entry().unit_price_krw = v)), + input(saved?.hours_per_unit ?? "", (v) => (entry().hours_per_unit = v)), + select(cuts, saved?.cut_unit_krw ?? "1", (v) => (entry().cut_unit_krw = v)), + select( + data.fields.corrections, + saved?.correction ?? "basic", + (v) => (entry().correction = v), + ), + ]; + for (const cell of cells) { + const td = el("td"); + td.append(cell); + tr.append(td); + } + tr.append( + el("td", "num", won(x?.labor_krw)), + el("td", "num", won(x?.material_krw)), + el("td", "num", won(x?.expense_krw)), + el("td", "num", won(x?.total_krw)), + el("td", "", machine.note ?? ""), + ); + table.append(tr); + } + box.append(table); + return box; +} + +function drawBill(data: ExecutionDto, draft: Settings): HTMLElement { + const box = el("div"); + box.append(el("strong", "", "실행예산 내역 — 최초수량 → 실행수량")); + const table = el("table", "b09ex__table"); + const head = el("tr"); + for (const label of [ + "공종번호", + "명칭", + "규격", + "단위", + "최초수량", + "금액(설계)", + "실행수량", + "실행 단가", + "실행 금액", + "비고", + ]) { + head.append(el("th", "", label)); + } + table.append(head); + for (const row of data.rows) { + const tr = el("tr", row.is_group ? "is-group" : ""); + tr.append( + el("td", "", row.item_no), + el("td", "", row.name), + el("td", "", row.spec ?? ""), + el("td", "", row.unit ?? ""), + el("td", "num", row.quantity ?? ""), + el("td", "num", won(row.amount_krw)), + ); + const cell = el("td"); + if (!row.is_group && row.in_bill && row.execution_quantity !== undefined) { + const qty = input(draft.quantities[row.item_no] ?? row.execution_quantity, (v) => { + if (v === "" || v === row.quantity) delete draft.quantities[row.item_no]; + else draft.quantities[row.item_no] = v; + }); + qty.step = "any"; + qty.classList.toggle("is-changed", row.item_no in draft.quantities); + cell.append(qty); + } + tr.append( + cell, + el("td", "num", won(row.execution_unit_price_krw)), + el("td", "num", won(row.execution_amount_krw)), + el("td", "", row.execution_note ?? ""), + ); + table.append(tr); + } + box.append(table); + return box; +} + +function drawBody(ctx: B09TabContext, data: ExecutionDto): void { + const draft = draftOf(ctx.projectId as string, data); + const wrap = el("div", "b09ex"); + wrap.append(el("div", "b09ex__warn", `⚠ ${data.limit_note}`)); + if (data.bill_missing_count) { + wrap.append( + el( + "div", + "b09ex__warn", + `⚠ 설계 내역 ${L("B09_Sheet_Missing")} ${data.bill_missing_count}${L("B09_Sheet_Count")} — 실행단가도 못 섬`, + ), + ); + } + wrap.append( + el("div", "b09ex__meta", "1단위당 중기사용료를 비우면 설계 사용료 그대로 — [저장]하면 반영"), + ); + const scroll = el("div", "b09ex__scroll"); + scroll.append(drawMachines(data, draft), drawBill(data, draft)); + wrap.append(scroll); + ctx.body.append(wrap); +} + +function render(ctx: B09TabContext): void { + injectStyles(); + if (!ctx.projectId) { + ctx.body.append(el("div", "b09ex__meta", "프로젝트를 고르세요")); + return; + } + const load = (): void => { + ctx.body.replaceChildren(el("div", "b09ex__meta", "실행예산 계산 중…")); + ctx.panel.replaceChildren(); + fetchExecution(ctx.projectId as string) + .then((data) => { + ctx.body.replaceChildren(); + drawPanel(ctx, data, load); + drawBody(ctx, data); + }) + .catch((error: unknown) => { + ctx.body.replaceChildren( + el( + "div", + "b09ex__warn", + `실행예산을 세우지 못함 — ${error instanceof Error ? error.message : ""}`, + ), + ); + }); + }; + load(); +} + +export const executionTab: B09Tab = { + key: "execution", + label: () => L("B09_Estimation_Tab_Execution"), + render, +}; diff --git a/B09_Estimation/B09_Estimation_UI_Tab_Lists.ts b/B09_Estimation/B09_Estimation_UI_Tab_Lists.ts new file mode 100644 index 00000000..a27534d8 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tab_Lists.ts @@ -0,0 +1,152 @@ +/* ============================================================================= + * B09_Estimation_UI_Tab_Lists.ts + * B09 목록표 탭 — 일곱 목록표를 한 자리에 (실무 `…목록표` 시트 · PLAN 12장) + * + * 일위대가 · 단가산출근거 · 중기 호표 · 명칭 · 규격 · 단위 · 합계 · 노무비 · 재료비 · 경비 · 비고 + * 재료비 · 노무비 · 경비 · 일식견적 호표 · 명칭 · 규격 · 단위 · 단가 · 비고 + * + * - ⚠ 목록표는 **색인**일 뿐 — 계산 쌍이 없음. 서버가 실은 번호·코드·단가를 그대로 늘어놓음. + * 번호는 내역에 처음 쓰인 차례(서버가 낼 때마다 매김). + * - 일위대가·산근·중기 줄을 누르면 그 호표로 들어감. + * ========================================================================== */ + +import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types"; +import { L, el, hint, numberCell, plainTable, segmented, won } from "./B09_Estimation_UI_Sheet"; +import { loadBill, type BillDto, type ResourceGroup } from "./B09_Estimation_UI_Store"; + +type ListKey = "unit_price" | "price_basis" | ResourceGroup; +let active: ListKey = "unit_price"; + +const LISTS: Array<[ListKey, Parameters[0]]> = [ + ["unit_price", "B09_Sheet_UnitPriceList"], + ["price_basis", "B09_Sheet_BasisList"], + ["machine", "B09_Sheet_MachineList"], + ["material", "B09_Sheet_List_Material"], + ["labor", "B09_Sheet_List_Labor"], + ["expense", "B09_Sheet_List_Expense"], + ["lumpsum", "B09_Sheet_List_Lumpsum"], +]; + +interface ListRow { + label: string; + name: string; + spec: string; + unit: string; + money: string[]; + note: string; + open?: [string, string]; +} + +function rowsOf(bill: BillDto, key: ListKey): ListRow[] { + if (key === "unit_price") { + return bill.unit_price_sheet.entries.map((entry) => ({ + label: entry.label, + name: entry.name, + spec: entry.spec, + unit: entry.unit, + money: [entry.total_krw ?? "", entry.labor_krw, entry.material_krw, entry.expense_krw], + note: entry.unconfirmed ? `${L("B09_Sheet_Unconfirmed")} ${entry.unconfirmed}` : "", + open: ["unit_price", entry.code], + })); + } + if (key === "price_basis") { + return bill.price_basis.entries.map((entry) => ({ + label: `${L("B09_Sheet_Basis_Long")} ${entry.number}${L("B09_Sheet_Basis_Suffix")}`, + name: entry.name, + spec: entry.spec, + unit: entry.unit, + money: [entry.unit_price_krw ?? "", entry.labor_krw, entry.material_krw, entry.expense_krw], + note: "", + open: ["price_basis", entry.code], + })); + } + return bill.lists[key].map((row) => ({ + label: String(row.number), + name: row.name, + spec: row.spec, + unit: row.unit, + money: + key === "machine" + ? [ + row.unit_price_krw ?? "", + row.unit_labor_krw ?? "", + row.unit_material_krw ?? "", + row.unit_expense_krw ?? "", + ] + : [row.unit_price_krw ?? ""], + note: row.note, + open: key === "machine" ? ["machine", row.code] : undefined, + })); +} + +function draw(ctx: B09TabContext, bill: BillDto): void { + ctx.body.append( + segmented( + LISTS.map(([key, label]) => [key, L(label)]), + active, + (key) => { + active = key as ListKey; + ctx.body.replaceChildren(); + draw(ctx, bill); + }, + ), + ); + const fourWay = active === "unit_price" || active === "price_basis" || active === "machine"; + const money = fourWay + ? [ + L("B09_Sheet_Col_Total"), + L("B09_Sheet_Col_Labor"), + L("B09_Sheet_Col_Material"), + L("B09_Sheet_Col_Expense"), + ] + : [L("B09_Sheet_Col_UnitPrice")]; + const { wrap, tbody } = plainTable([ + L("B09_Sheet_Col_Sheet"), + L("B09_Sheet_Col_Name"), + L("B09_Sheet_Col_Spec"), + L("B09_Sheet_Col_Unit"), + ...money, + L("B09_Sheet_Col_Note"), + ]); + const rows = rowsOf(bill, active); + for (const row of rows) { + const tr = el("tr"); + tr.append( + el("td", "", row.label), + el("td", "", row.name), + el("td", "", row.spec), + el("td", "", row.unit), + ); + for (const value of row.money) tr.append(numberCell(won(value))); + tr.append(el("td", "b09s-note", row.note)); + if (row.open) { + const [tab, code] = row.open; + tr.classList.add("is-clickable"); + tr.title = L("B09_Sheet_Drill"); + tr.addEventListener("click", () => ctx.open(tab, code)); + } + tbody.append(tr); + } + ctx.body.append(wrap); + if (rows.length === 0) ctx.body.append(hint(L("B09_Sheet_EmptyGroup"))); +} + +export const listsTab: B09Tab = { + key: "lists", + label: () => L("B09_Sheet_Tab_Lists"), + render(ctx) { + if (!ctx.projectId) { + ctx.body.append(hint(L("B09_Sheet_NoProject"))); + return; + } + ctx.body.append(hint(L("B09_Sheet_Loading"))); + loadBill(ctx.projectId) + .then((bill) => { + ctx.body.replaceChildren(); + draw(ctx, bill); + }) + .catch((error: Error) => { + ctx.body.replaceChildren(hint(`${L("B09_Sheet_LoadFailed")} ${error.message}`, true)); + }); + }, +}; diff --git a/B09_Estimation/B09_Estimation_UI_Tab_Machine.ts b/B09_Estimation/B09_Estimation_UI_Tab_Machine.ts new file mode 100644 index 00000000..9c68203e --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tab_Machine.ts @@ -0,0 +1,91 @@ +/* ============================================================================= + * B09_Estimation_UI_Tab_Machine.ts + * B09 중기 탭 — 중기목록표 + 중기사용료 (실무 `중기목록표`·`중기사용료` 시트 · PLAN 12장) + * + * - 목록표 = 내역이 쓴 시간당 중기만, 처음 쓰인 차례(서버 `lists.machine`). + * - 줄을 누르면 아래에 그 기종의 중기사용료 호표 — 손료(경비) · 운전원(노무) · 주연료(재료) · 잡품(재료). + * ⚠ 잡품은 「주연료비 × 율」 가산 행 — 수량 칸에 율(%), 단가 칸에 밑수(주연료비)가 옴(실무 표 그대로). + * - 그 아래 중기경비계산서(취득가·손료계수·연료·조종원 과정) — _UI_MachineExpense. + * ========================================================================== */ + +import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types"; +import { renderDetail } from "./B09_Estimation_UI_Detail"; +import { drawMachineExpense, fetchMachineExpense } from "./B09_Estimation_UI_MachineExpense"; +import { L, el, hint, numberCell, plainTable, won } from "./B09_Estimation_UI_Sheet"; +import { loadBill, type BillDto } from "./B09_Estimation_UI_Store"; + +let selected = ""; + +function draw(ctx: B09TabContext, bill: BillDto): void { + const rows = bill.lists.machine; + const { wrap, tbody } = plainTable([ + L("B09_Sheet_Col_Sheet"), + L("B09_Sheet_Col_Name"), + L("B09_Sheet_Col_Spec"), + L("B09_Sheet_Col_Unit"), + L("B09_Sheet_Col_Total"), + L("B09_Sheet_Col_Labor"), + L("B09_Sheet_Col_Material"), + L("B09_Sheet_Col_Expense"), + L("B09_Sheet_Col_Note"), + ]); + for (const row of rows) { + const tr = el("tr", `is-clickable${row.code === selected ? " is-selected" : ""}`); + tr.append( + el("td", "", String(row.number)), + el("td", "", row.name), + el("td", "", row.spec), + el("td", "", row.unit), + numberCell(won(row.unit_price_krw)), + numberCell(won(row.unit_labor_krw)), + numberCell(won(row.unit_material_krw)), + numberCell(won(row.unit_expense_krw)), + el("td", "b09s-note", row.note), + ); + tr.addEventListener("click", () => { + selected = row.code; + ctx.body.replaceChildren(); + draw(ctx, bill); + }); + tbody.append(tr); + } + const detail = el("div", "b09s-split"); + const expense = el("div", "b09s-split"); + ctx.body.append(el("div", "b09s-title", L("B09_Sheet_MachineList")), wrap); + if (rows.length === 0) ctx.body.append(hint(L("B09_Sheet_EmptyList"))); + ctx.body.append(detail, expense); + if (selected) { + const picked = rows.find((row) => row.code === selected); + const label = picked ? `${L("B09_Sheet_Col_Sheet")} ${picked.number}` : selected; + renderDetail(ctx, detail, "machine", selected, label); + } else { + detail.append(hint(L("B09_Sheet_PickSheet"))); + } + if (ctx.projectId) { + // 중기경비계산서 — 「그 값이 왜 나왔나」(옛 표를 옮겨 온 _UI_MachineExpense). + void fetchMachineExpense(ctx.projectId) + .then((data) => drawMachineExpense(expense, data)) + .catch(() => expense.append(hint(L("B09_Sheet_LoadFailed"), true))); + } +} + +export const machineTab: B09Tab = { + key: "machine", + label: () => L("B09_Estimation_Tab_Machine"), + render(ctx, arg) { + if (!ctx.projectId) { + ctx.body.append(hint(L("B09_Sheet_NoProject"))); + return; + } + if (arg) selected = arg; + ctx.body.append(hint(L("B09_Sheet_Loading"))); + loadBill(ctx.projectId) + .then((bill) => { + ctx.body.replaceChildren(); + draw(ctx, bill); + }) + .catch((error: Error) => { + ctx.body.replaceChildren(hint(`${L("B09_Sheet_LoadFailed")} ${error.message}`, true)); + }); + }, +}; diff --git a/B09_Estimation/B09_Estimation_UI_Tab_MaterialPrices.ts b/B09_Estimation/B09_Estimation_UI_Tab_MaterialPrices.ts new file mode 100644 index 00000000..42b2da13 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tab_MaterialPrices.ts @@ -0,0 +1,205 @@ +/* ============================================================================= + * B09_Estimation_UI_Tab_MaterialPrices.ts + * 자재 단가 탭 — 자재 수동 단가 칸 (PLAN 1장 Ⓐ · 랩탑 메인 · 2026-09-14 브레인 판정) + * + * - 줄 = 일위대가 자원 축이 쓰는 자재(코드 `AR-M-…`) · 코드 · 명칭 · 규격 · 단위 · 쓰인 공종 · + * 단가 · 출처 · 넣은 날. 넣은 줄 = 빨간 테두리(수동 단가 = 미확정 · 구조물도·폐기물과 같은 꼴). + * - [저장] = 바뀐 줄만 서버로. 단가 칸을 비우면 그 줄을 지움(단가 없음으로 돌아감) · 0 이하는 서버가 거절. + * - ⚠ 금액은 서버가 다시 조립 — 여기서 곱하지 않음. 저장 뒤 내역서·일위대가 탭은 새로 받음. + * - 자원 축에서 사라진 코드의 저장 줄도 조용히 안 버림 — 경고와 함께 보이고 지울 수 있음. + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { showToast } from "@ui/ui_template_elements"; +import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types"; +import { L, el, hint, injectSheetStyles, plainTable, won } from "./B09_Estimation_UI_Sheet"; + +interface MaterialPriceRow { + key: string; + name: string; + spec: string; + unit: string; + work_items: string[]; + price_krw: string | null; + source: string; + entered_at: string; + missing: boolean; + /** `unit_price` = 일위대가 자원(코드 키) · `material_sheet` = 자재총괄(이름 규격 키). */ + origin?: string; + supply_type?: string; + quantity?: string; +} + +interface MaterialPricesDto { + status: string; + message?: string; + rows: MaterialPriceRow[]; + unconfirmed_count: number; + handoff_note?: string; +} + +const SUPPLY_LABELS: Record = { + contractor_supplied: "사급", + owner_supplied: "관급 — 도급 금액 밖(수량만)", + unknown: "미정 — 관급구분을 먼저 고름(B08 자재총괄)", +}; + +function whereText(row: MaterialPriceRow): string { + if (row.missing) return "⚠ 목록에서 사라진 자재 — 단가를 비우고 저장해 지움"; + if (row.origin === "material_sheet") { + return `자재총괄 ${row.quantity ?? ""}${row.unit} · ${row.work_items.join(", ")} · ${ + SUPPLY_LABELS[row.supply_type ?? ""] ?? row.supply_type ?? "" + }`; + } + return `일위대가 · ${row.work_items.join(", ")}`; +} + +interface Change { + key: string; + price_krw: string; + source: string; + name: string; + spec: string; + unit: string; +} + +async function request(projectId: string, init: RequestInit = {}): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/material-prices`, + { credentials: "include", ...init }, + ); + const body = (await response.json()) as MaterialPricesDto; + if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`); + return body; +} + +function input(value: string, width: string, placeholder = ""): HTMLInputElement { + const node = el("input", "b09s-edit-input"); + node.value = value; + node.placeholder = placeholder; + node.style.width = width; + return node; +} + +function draw(ctx: B09TabContext, projectId: string, data: MaterialPricesDto): void { + const drafts: Array<{ + row: MaterialPriceRow; + price: HTMLInputElement; + source: HTMLInputElement; + }> = []; + const save = el("button", "b09s-undo", "저장"); + save.type = "button"; + const bar = el("div", "b09s-bar"); + bar.append(el("span", "b09s-title", L("B09_Estimation_Tab_MaterialPrices")), save); + if (data.unconfirmed_count > 0) { + bar.append(el("span", "b09s-badge", `수동 단가 ${data.unconfirmed_count}건 — 미확정`)); + } + + const { wrap, tbody } = plainTable([ + "코드·키", + "명칭", + "규격", + "단위", + "쓰이는 곳", + "단가(원)", + "출처", + "넣은 날", + ]); + for (const row of data.rows) { + const tr = el("tr", row.price_krw ? "is-manual" : ""); + const price = input(row.price_krw ?? "", "96px", "비움 = 단가 없음"); + price.inputMode = "decimal"; + price.title = row.price_krw ? won(row.price_krw) : ""; + const source = input(row.source, "160px", "견적 업체·물가지 쪽"); + // 자재총괄 줄은 사급만 금액이 섬 — 관급·미정 줄은 칸을 잠금(값이 남아 있으면 지울 수는 있게). + const locked = + row.origin === "material_sheet" && + row.supply_type !== "contractor_supplied" && + !row.price_krw; + price.disabled = locked; + source.disabled = locked; + const priceCell = el("td", "b09s-num"); + priceCell.append(price); + const sourceCell = el("td"); + sourceCell.append(source); + tr.append( + el("td", "", row.key), + el("td", "", row.name), + el("td", "", row.spec), + el("td", "", row.unit), + el("td", "b09s-note", whereText(row)), + priceCell, + sourceCell, + el("td", "", row.entered_at), + ); + tbody.append(tr); + drafts.push({ row, price, source }); + } + + save.addEventListener("click", () => { + const changes: Change[] = drafts + .filter( + ({ row, price, source }) => + price.value.trim() !== (row.price_krw ?? "") || source.value.trim() !== row.source, + ) + .map(({ row, price, source }) => ({ + key: row.key, + price_krw: price.value.trim(), + source: source.value.trim(), + name: row.name, + spec: row.spec, + unit: row.unit, + })); + if (changes.length === 0) return; + save.disabled = true; + request(projectId, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ changes }), + }) + .then((next) => { + showToast(L("B09_Sheet_Saved"), "success"); + ctx.body.replaceChildren(); + draw(ctx, projectId, next); + }) + .catch((error: Error) => { + save.disabled = false; + showToast(`${L("B09_Sheet_SaveFailed")} ${error.message}`, "error"); + }); + }); + + ctx.body.append(bar, wrap); + if (data.handoff_note) ctx.body.append(hint(data.handoff_note, true)); + if (data.rows.length === 0) ctx.body.append(hint("단가 칸을 낼 자재가 없음")); + ctx.body.append( + hint( + "일위대가 자재 — 6번 슬롯(적용 단가)으로 서고 닿은 내역 줄마다 「미확정」으로 셈 · 자재단가대비표에도 보임", + ), + hint( + "자재총괄 사급 자재 — 내역서 끝 「자재(사급)」 줄로 섬(할증 뒤 수량 × 단가) · 관급은 도급 금액 밖", + ), + hint("값·출처가 같으면 다시 저장해도 넣은 날은 그대로"), + ); +} + +export const materialPricesTab: B09Tab = { + key: "material_prices", + label: () => L("B09_Estimation_Tab_MaterialPrices"), + render(ctx) { + injectSheetStyles(); + if (!ctx.projectId) { + ctx.body.append(hint(L("B09_Sheet_NoProject"))); + return; + } + const projectId = ctx.projectId; + ctx.body.append(hint(L("B09_Sheet_Loading"))); + request(projectId) + .then((data) => { + ctx.body.replaceChildren(); + draw(ctx, projectId, data); + }) + .catch((error: Error) => { + ctx.body.replaceChildren(hint(`${L("B09_Sheet_LoadFailed")} ${error.message}`, true)); + }); + }, +}; diff --git a/B09_Estimation/B09_Estimation_UI_Tab_PriceBasis.ts b/B09_Estimation/B09_Estimation_UI_Tab_PriceBasis.ts new file mode 100644 index 00000000..dcb47e96 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tab_PriceBasis.ts @@ -0,0 +1,76 @@ +/* ============================================================================= + * B09_Estimation_UI_Tab_PriceBasis.ts + * B09 단가산출근거 탭 — 목록표 + 단가산출근거 (STmate `단가산출근거목록표` · `wM_Edit_San` 보기 · PLAN 12장 1차) + * + * - 목록표 = 내역 일위대가가 품은 D 만, **내역에 처음 쓰인 차례**로 「산근 N호표」(내역 비고 「단산 N」과 한 번호). + * - 본표 줄의 비고에 Q 식·품셈 근거를 **글자 그대로** 보임(서버 줄 `note`). + * - 「부르는 호표」 칸의 글을 누르면 그 일위대가표로 되돌아 들어감. + * - Q 식 편집은 2차(브레인 판정). + * ========================================================================== */ + +import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types"; +import { renderDetail } from "./B09_Estimation_UI_Detail"; +import { L, el, hint, linkButton, sheetTable } from "./B09_Estimation_UI_Sheet"; +import { listHead, listRow } from "./B09_Estimation_UI_Tab_UnitPrice"; +import { loadBill, type BillDto } from "./B09_Estimation_UI_Store"; + +let selected = ""; + +function draw(ctx: B09TabContext, bill: BillDto): void { + const entries = bill.price_basis.entries; + const page = el("div", "b09s-split"); + const { wrap, tbody } = sheetTable(listHead(L("B09_Sheet_Col_Basis"))); + const detail = el("div", "b09s-split"); + for (const entry of entries) { + const row = { + ...entry, + label: `${L("B09_Sheet_Basis_Long")} ${entry.number}${L("B09_Sheet_Basis_Suffix")}`, + }; + const tr = listRow(row, entry.unit_price_krw ?? "", entry.code === selected); + const note = tr.lastElementChild as HTMLElement; + for (const code of entry.unit_price_codes ?? []) { + const sheet = bill.unit_price_sheet.entries.find((item) => item.code === code); + if (sheet) note.append(linkButton(sheet.label, () => ctx.open("unit_price", code))); + } + tr.addEventListener("click", () => { + selected = entry.code; + ctx.body.replaceChildren(); + draw(ctx, bill); + }); + tbody.append(tr); + } + page.append(el("div", "b09s-title", L("B09_Sheet_BasisList")), wrap); + if (entries.length === 0) page.append(hint(L("B09_Sheet_EmptyList"))); + page.append(detail); + ctx.body.append(page); + if (!selected) { + detail.append(hint(L("B09_Sheet_PickSheet"))); + return; + } + const picked = entries.find((entry) => entry.code === selected); + const label = picked + ? `${L("B09_Sheet_Basis_Long")} ${picked.number}${L("B09_Sheet_Basis_Suffix")}` + : selected; + renderDetail(ctx, detail, "price_basis", selected, label); +} + +export const priceBasisTab: B09Tab = { + key: "price_basis", + label: () => L("B09_Estimation_Tab_PriceBasis"), + render(ctx, arg) { + if (!ctx.projectId) { + ctx.body.append(hint(L("B09_Sheet_NoProject"))); + return; + } + if (arg) selected = arg; + ctx.body.append(hint(L("B09_Sheet_Loading"))); + loadBill(ctx.projectId) + .then((bill) => { + ctx.body.replaceChildren(); + draw(ctx, bill); + }) + .catch((error: Error) => { + ctx.body.replaceChildren(hint(`${L("B09_Sheet_LoadFailed")} ${error.message}`, true)); + }); + }, +}; diff --git a/B09_Estimation/B09_Estimation_UI_Tab_PriceCompare.ts b/B09_Estimation/B09_Estimation_UI_Tab_PriceCompare.ts new file mode 100644 index 00000000..d757148c --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tab_PriceCompare.ts @@ -0,0 +1,169 @@ +/* ============================================================================= + * B09_Estimation_UI_Tab_PriceCompare.ts + * B09 자재단가대비표 탭 — 원천 슬롯 여섯 · 채택 · 최소단가 (실무 `자재단가대비표` · STmate `wM_Boxa`) + * + * - 칸: 호표 · 명칭 · 규격 · 단위 · 슬롯 1~6(단가 · 페이지) · 채택 · 비고. + * - 채택 슬롯 = 굵게·색 · 최소단가 = 「최소」 표시(서버 `min_slot`). 값 없는 슬롯은 빈칸(0 아님). + * - 2차(PLAN 12장 · 프로젝트 단위) — 줄마다 채택 슬롯을 고르거나 「변동없음 / 1~5 단가 / 최소단가」로 일괄. + * 고친 줄 = 「사용자」 표시 + ↺(지우면 계산값으로). 값 없는 슬롯은 서버가 받지 않음. + * - ⚠ 저장은 고른 슬롯 번호만 — 금액은 서버가 다시 셈(다른 탭은 내역 한 벌을 새로 받음). + * ========================================================================== */ + +import { showToast } from "@ui/ui_template_elements"; +import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types"; +import { L, el, hint, numberCell, sheetTable, userMark, won } from "./B09_Estimation_UI_Sheet"; +import { + loadEdits, + loadPriceCompare, + saveEdits, + type EditChange, + type EditsDto, + type PriceCompareDto, +} from "./B09_Estimation_UI_Store"; + +const SECTION = "adopted_slots"; + +function head(slotNames: string[]): HTMLElement { + const thead = el("thead"); + const top = el("tr"); + const bottom = el("tr"); + const fixed = [ + L("B09_Sheet_Col_Sheet"), + L("B09_Sheet_Col_Name"), + L("B09_Sheet_Col_Spec"), + L("B09_Sheet_Col_Unit"), + ]; + for (const label of fixed) { + const th = el("th", "", label); + th.rowSpan = 2; + top.append(th); + } + slotNames.forEach((name, index) => { + const th = el("th", "", `${index + 1} ${name}`); + th.colSpan = 2; + top.append(th); + bottom.append( + el("th", "", L("B09_Sheet_Col_UnitPrice")), + el("th", "", L("B09_Sheet_Col_Page")), + ); + }); + for (const label of [L("B09_Sheet_Adopt"), L("B09_Sheet_Col_Note")]) { + const th = el("th", "", label); + th.rowSpan = 2; + top.append(th); + } + thead.append(top, bottom); + return thead; +} + +type Row = PriceCompareDto["material_comparison"]["rows"][number]; + +function adoptPicker(row: Row, onPick: (slot: number) => void): HTMLSelectElement { + const select = el("select"); + row.slots.forEach((slot, index) => { + if (slot.price_krw === null) return; // 값 없는 원천은 고를 수 없음 + const option = el("option", "", `${index + 1} ${won(slot.price_krw)}`); + option.value = String(index + 1); + option.selected = index + 1 === row.adopted_slot; + select.append(option); + }); + select.addEventListener("change", () => onPick(Number(select.value))); + return select; +} + +/** 일괄 — 「변동없음 / 1~5 단가 / 최소단가」. 그 슬롯에 값이 있는 줄만 고침(STmate `wM_Boxa`). */ +function bulkChanges(rows: Row[], choice: string): EditChange[] { + if (choice === "none") return []; + return rows.flatMap((row) => { + const slot = choice === "min" ? row.min_slot : Number(choice); + if (!slot || row.slots[slot - 1]?.price_krw === null) return []; + return [{ section: SECTION, key: row.code, value: slot }]; + }); +} + +function draw(ctx: B09TabContext, projectId: string, data: PriceCompareDto, edits: EditsDto): void { + const table = data.material_comparison; + const userSlots = edits[SECTION] ?? {}; + const save = (changes: EditChange[]): void => { + if (changes.length === 0) return; + void saveEdits(projectId, changes) + .then(() => { + showToast(L("B09_Sheet_Saved"), "success"); + ctx.open("price_compare"); + }) + .catch((error: Error) => showToast(`${L("B09_Sheet_SaveFailed")} ${error.message}`, "error")); + }; + + const bar = el("div", "b09s-bar"); + const bulk = el("select"); + const choices: Array<[string, string]> = [ + ["none", L("B09_Sheet_Bulk_None")], + ...[1, 2, 3, 4, 5].map((n): [string, string] => [ + String(n), + `${n} ${table.slot_names[n - 1] ?? ""}`, + ]), + ["min", L("B09_Sheet_Bulk_Min")], + ]; + for (const [value, label] of choices) { + const option = el("option", "", label); + option.value = value; + bulk.append(option); + } + const apply = el("button", "b09s-undo", L("B09_Sheet_Apply")); + apply.type = "button"; + apply.addEventListener("click", () => save(bulkChanges(table.rows, bulk.value))); + bar.append(el("span", "b09s-head", L("B09_Sheet_Bulk")), bulk, apply); + + const { wrap, tbody } = sheetTable(head(table.slot_names)); + table.rows.forEach((row, index) => { + const tr = el("tr", row.code in userSlots ? "is-user" : ""); + tr.append( + el("td", "", String(index + 1)), + el("td", "", row.name), + el("td", "", row.spec), + el("td", "", row.unit), + ); + row.slots.forEach((slot, slotIndex) => { + const price = numberCell(won(slot.price_krw)); + if (slot.adopted) price.classList.add("b09s-adopted"); + if (row.min_slot === slotIndex + 1) price.append(el("span", "b09s-min", L("B09_Sheet_Min"))); + tr.append(price, el("td", "", slot.source_note)); + }); + const adopt = el("td"); + adopt.append( + adoptPicker(row, (slot) => save([{ section: SECTION, key: row.code, value: slot }])), + ); + if (row.code in userSlots) { + adopt.append(userMark(() => save([{ section: SECTION, key: row.code, value: null }]))); + } + tr.append(adopt, el("td", "b09s-note", row.note)); + tbody.append(tr); + }); + ctx.body.append(el("div", "b09s-title", L("B09_Sheet_Tab_PriceCompare")), bar, wrap); + if (table.rows.length === 0) ctx.body.append(hint(L("B09_Sheet_EmptyGroup"))); + for (const note of table.notes) ctx.body.append(hint(note)); +} + +export const priceCompareTab: B09Tab = { + key: "price_compare", + label: () => L("B09_Sheet_Tab_PriceCompare"), + render(ctx) { + if (!ctx.projectId) { + ctx.body.append(hint(L("B09_Sheet_NoProject"))); + return; + } + const projectId = ctx.projectId; + ctx.body.append(hint(L("B09_Sheet_Loading"))); + Promise.all([loadPriceCompare(projectId), loadEdits(projectId)]) + .then(([data, stored]) => { + ctx.body.replaceChildren(); + draw(ctx, projectId, data, stored.edits); + for (const line of stored.skipped) { + ctx.body.append(hint(`${L("B09_Sheet_EditSkipped")} ${line}`, true)); + } + }) + .catch((error: Error) => { + ctx.body.replaceChildren(hint(`${L("B09_Sheet_LoadFailed")} ${error.message}`, true)); + }); + }, +}; diff --git a/B09_Estimation/B09_Estimation_UI_Tab_Progress.ts b/B09_Estimation/B09_Estimation_UI_Tab_Progress.ts new file mode 100644 index 00000000..6ec42e84 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tab_Progress.ts @@ -0,0 +1,510 @@ +/* ============================================================================= + * B09_Estimation_UI_Tab_Progress.ts + * 기성 탭 — STmate 「기성 제잡비 계산서」(wM_KanJub_K)와 기성내역서(계약|전회|금회|누계)를 본뜸 (PLAN 12장 · 랩탑 메인). + * + * - 좌측 = 회차 고르기 · [회차 추가] · 부가세 방식 넷(직접입력이면 금액) · [저장]. + * - 본문 위 = 기성 제잡비 계산서 — 도급액 · 계약잡비율 · 전회/금회/누계 금액과 율. + * - 본문 아래 = 기성내역 — 계약 · 전회 · **금회 기성수량 칸** · 누계 · 기성(%) · 잔량. + * - ⚠ 값은 서버(`/estimation/progress`)가 계약내역·계약 원가계산서에서 파생 — 계약은 안 바뀜. + * - ⚠ 기성 표본이 없어 「구조가 선다」까지만 확인된 화면 — 머리에 그 한계를 적음. + * ========================================================================== */ + +import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, showToast } from "@ui/ui_template_elements"; +import { API_BASE_URL } from "@config/config_frontend"; +import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +type Column = "previous" | "current" | "cumulative"; +const COLUMNS: [Column, string, string][] = [ + ["previous", "전회금액", "전율"], + ["current", "금회금액", "금율"], + ["cumulative", "누계금액", "누율"], +]; + +interface Round { + quantities: Record; + vat_mode: string; + vat_manual_krw: string; + supply_cut_krw: string; + total_cut_krw: string; + profit_manual_krw: string; + /** 사정 — 뜻 미확인이라 칸만(계산에 안 씀). */ + assessed: Record; +} + +type Override = { rate_pct: string; reason: string }; +type Overrides = Record; +type Choice = { key: string; label: string }; + +interface AmountRow { + key: string; + name: string; + contract_krw: string; + contract_ratio_pct?: string | null; + default_ratio_pct?: string; + override?: Override | null; + [column: string]: string | null | undefined | Override; +} + +interface ProgressRow { + item_no: string; + name: string; + spec: string; + unit: string; + quantity: string | null; + is_group: boolean; + in_bill: boolean; + contract_amount_krw?: string; + progress_pct?: string | null; + progress_remaining_quantity?: string; + progress_note?: string; + [column: string]: string | boolean | null | undefined; +} + +interface ProgressDto { + status: string; + message?: string; + rows: ProgressRow[]; + items: AmountRow[]; + summary: AmountRow[]; + contract_overhead_ratio_pct: string | null; + round: number; + round_count: number; + notes: string[]; + settings: { rounds: Round[]; rate_overrides: Overrides }; + fields: { vat: Choice[]; supply_cut: Choice[]; total_cut: Choice[] }; + limit_note: string; +} + +const STYLE_ID = "b09-progress-styles"; +function injectStyles(): void { + if (document.getElementById(STYLE_ID)) return; + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = ` +.b09pg { display: flex; flex-direction: column; gap: 8px; height: 100%; min-height: 0; } +.b09pg__meta { font-size: 12px; color: var(--color-text-secondary); } +.b09pg__warn { font-size: 12px; color: var(--color-warning-text, #8a5a00); } +.b09pg__scroll { flex: 1; overflow: auto; min-height: 0; display: flex; flex-direction: column; gap: 12px; } +.b09pg__table { border-collapse: collapse; font-size: 12px; white-space: nowrap; } +.b09pg__table th, .b09pg__table td { border: 1px solid var(--color-border); padding: 2px 6px; } +.b09pg__table td.num { text-align: right; font-variant-numeric: tabular-nums; } +.b09pg__table tr.is-group td, .b09pg__table tr.is-total td { font-weight: 600; } +.b09pg__table input { width: 7em; text-align: right; } +.b09pg__panel { display: flex; flex-direction: column; gap: 6px; font-size: 12px; } +.b09pg__panel label { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; } +`; + document.head.append(style); +} + +function el( + tag: K, + className = "", + text = "", +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + if (className) node.className = className; + if (text) node.textContent = text; + return node; +} + +function won(value: string | boolean | null | undefined): string { + if (value === null || value === undefined || value === "" || typeof value === "boolean") + return ""; + const n = Number(value); + return Number.isFinite(n) ? n.toLocaleString("ko-KR") : value; +} + +function pct(value: string | boolean | null | undefined): string { + return typeof value === "string" ? `${value}%` : ""; +} + +/** 프로젝트별 입력 캐시 — [저장] 전 값(지침 5장 · 자동저장 없음). 고른 회차도 함께. */ +const drafts = new Map(); + +function draftOf(projectId: string, data: ProgressDto) { + let draft = drafts.get(projectId); + if (!draft) { + draft = { + rounds: structuredClone(data.settings.rounds), + overrides: structuredClone(data.settings.rate_overrides), + round: data.round, + }; + drafts.set(projectId, draft); + } + return draft; +} + +function endpoint(projectId: string): string { + return `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/progress`; +} + +async function fetchProgress(projectId: string, round: number | null): Promise { + const query = round ? `?round=${round}` : ""; + const response = await fetch(`${endpoint(projectId)}${query}`, { credentials: "include" }); + const body = (await response.json()) as ProgressDto; + if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`); + return body; +} + +async function saveProgress( + projectId: string, + rounds: Round[], + overrides: Overrides, +): Promise { + const response = await fetch(endpoint(projectId), { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rounds, rate_overrides: overrides }), + }); + const body = (await response.json()) as { message?: string }; + if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`); +} + +function choiceSelect(choices: Choice[], value: string, onChange: (v: string) => void) { + const node = el("select"); + for (const choice of choices) { + const option = el("option", "", choice.label); + option.value = choice.key; + node.append(option); + } + node.value = value; + node.addEventListener("change", () => onChange(node.value)); + return node; +} + +/** 공급가액 절사 · 총공사비 절사(9택) · 이윤금액 직접입력 — 떨어지는 몫은 이윤에서. */ +function drawCuts(data: ProgressDto, current: Round): HTMLElement[] { + const supply = el("label"); + supply.append( + el("span", "", "공급가액 절사"), + choiceSelect( + data.fields.supply_cut, + current.supply_cut_krw, + (v) => (current.supply_cut_krw = v), + ), + ); + const total = el("label"); + const profit = el("input"); + profit.type = "number"; + profit.min = "0"; + profit.placeholder = "이윤금액(비우면 계약잡비율 값)"; + profit.value = current.profit_manual_krw; + profit.hidden = current.total_cut_krw !== ""; + profit.addEventListener("input", () => (current.profit_manual_krw = profit.value)); + total.append( + el("span", "", "총공사비 절사"), + choiceSelect(data.fields.total_cut, current.total_cut_krw, (v) => { + current.total_cut_krw = v; + profit.hidden = v !== ""; + }), + profit, + ); + return [supply, total]; +} + +function drawPanel(ctx: B09TabContext, data: ProgressDto, reload: (round: number) => void): void { + const projectId = ctx.projectId as string; + const draft = draftOf(projectId, data); + const box = el("div", "b09pg__panel"); + const save = async (round: number): Promise => { + try { + await saveProgress(projectId, draft.rounds, draft.overrides); + drafts.delete(projectId); + showToast("기성 회차 저장 — 계약 내역은 그대로", "success"); + reload(round); + } catch (error) { + showToast(error instanceof Error ? error.message : "저장 못 함", "error"); + } + }; + const pick = el("label"); + pick.append(el("span", "", "회차")); + const select = el("select"); + for (let n = 1; n <= draft.rounds.length; n += 1) { + const option = el("option", "", `${n}회`); + option.value = String(n); + select.append(option); + } + select.value = String(data.round); + select.addEventListener("change", () => reload(Number(select.value))); + pick.append(select); + box.append(pick); + box.append( + createButton({ + label: "회차 추가(저장)", + variant: "ghost", + onClick: () => { + const last = draft.rounds[draft.rounds.length - 1]; + draft.rounds.push({ + quantities: {}, + vat_mode: last?.vat_mode ?? "supply", + vat_manual_krw: "", + supply_cut_krw: last?.supply_cut_krw ?? "", + total_cut_krw: last?.total_cut_krw ?? "", + profit_manual_krw: "", + assessed: {}, + }); + void save(draft.rounds.length); + }, + }), + ); + if (draft.rounds.length) { + box.append( + createButton({ + label: `${draft.rounds.length}회 지우기(저장)`, + variant: "ghost", + onClick: () => { + // 앞 회차를 지우면 뒤 회차의 전회가 통째로 바뀜 — 마지막 회차만 지움. + draft.rounds.pop(); + void save(draft.rounds.length); + }, + }), + ); + } + const current = draft.rounds[data.round - 1]; + if (current) { + const vat = el("label"); + vat.append(el("span", "", "부가세")); + const mode = el("select"); + for (const choice of data.fields.vat) { + const option = el("option", "", choice.label); + option.value = choice.key; + mode.append(option); + } + mode.value = current.vat_mode; + const manual = el("input"); + manual.type = "number"; + manual.min = "0"; + manual.placeholder = "직접입력 금액"; + manual.value = current.vat_manual_krw; + manual.hidden = current.vat_mode !== "manual"; + mode.addEventListener("change", () => { + current.vat_mode = mode.value; + manual.hidden = mode.value !== "manual"; + }); + manual.addEventListener("input", () => (current.vat_manual_krw = manual.value)); + vat.append(mode, manual); + box.append(vat, ...drawCuts(data, current)); + box.append(createButton({ label: "저장", onClick: () => save(data.round) })); + } else { + box.append(el("div", "b09pg__meta", "회차 없음 — [회차 추가]로 1회부터")); + } + ctx.panel.append(box); +} + +/** 기성 제잡비 계산서 — 제잡비 줄 + 합계 줄(직접공사비 · 제잡비 계 · 공급가액 · 부가세 · 기성금액). */ +function drawJab(data: ProgressDto, overrides: Overrides): HTMLElement { + const box = el("div"); + box.append( + el( + "strong", + "", + `기성 제잡비 계산서 — 금회직접공사비 × 계약잡비율 · 계약 제잡비율 ${pct(data.contract_overhead_ratio_pct)}`, + ), + el( + "div", + "b09pg__meta", + "계약잡비율 기본 = 역산(도급액 ÷ 계약 직접공사비) · 계약서 값이 다르면 직접입력 + 사유 → [저장] · ↺ 로 역산값", + ), + ); + const table = el("table", "b09pg__table"); + const head = el("tr"); + for (const label of [ + "명칭", + "도급액", + "계약잡비율", + "직접입력(%)", + "사유", + "", + ...COLUMNS.flatMap(([, a, r]) => [a, r]), + ]) { + head.append(el("th", "", label)); + } + table.append(head); + const line = (row: AmountRow, total: boolean): void => { + const tr = el("tr", total ? "is-total" : ""); + const ratio = el("td", "num", pct(row.contract_ratio_pct)); + if (row.override) ratio.title = `역산값 ${row.default_ratio_pct}% — 직접입력이 이김`; + tr.append(el("td", "", row.name), el("td", "num", won(row.contract_krw)), ratio); + const cells = [el("td"), el("td"), el("td")]; + if (!total) { + const entry = (): Override => (overrides[row.key] ??= { rate_pct: "", reason: "" }); + const rate = el("input"); + rate.type = "number"; + rate.min = "0"; + rate.step = "any"; + rate.placeholder = row.default_ratio_pct ?? ""; + rate.value = overrides[row.key]?.rate_pct ?? ""; + rate.addEventListener("input", () => (entry().rate_pct = rate.value)); + const reason = el("input"); + reason.placeholder = "사유(계약서 등)"; + reason.style.width = "12em"; + reason.style.textAlign = "left"; + reason.value = overrides[row.key]?.reason ?? ""; + reason.addEventListener("input", () => (entry().reason = reason.value)); + const revert = el("button", "", "↺"); + revert.type = "button"; + revert.title = "역산값으로 되돌림 — [저장]하면 반영"; + revert.disabled = !overrides[row.key]; + revert.addEventListener("click", () => { + delete overrides[row.key]; + rate.value = ""; + reason.value = ""; + }); + cells[0].append(rate); + cells[1].append(reason); + cells[2].append(revert); + } + tr.append(...cells); + for (const [col] of COLUMNS) { + const text = (key: string): string | null => { + const value = row[key]; + return typeof value === "string" ? value : null; + }; + tr.append(el("td", "num", won(text(`${col}_krw`))), el("td", "num", pct(text(`${col}_pct`)))); + } + table.append(tr); + }; + for (const row of data.items) line(row, false); + for (const row of data.summary) line(row, true); + box.append(table); + return box; +} + +function drawBill(data: ProgressDto, rounds: Round[]): HTMLElement { + const box = el("div"); + box.append(el("strong", "", `기성내역 — ${data.round ? `${data.round}회` : "회차 없음"}`)); + const table = el("table", "b09pg__table"); + const head = el("tr"); + for (const label of [ + "공종번호", + "명칭", + "규격", + "단위", + "계약수량", + "계약금액", + "전회수량", + "전회금액", + "금회수량", + "금회금액", + "사정(칸만)", + "누계수량", + "누계금액", + "기성(%)", + "잔량", + "비고", + ]) { + head.append(el("th", "", label)); + } + table.append(head); + const current = rounds[data.round - 1]; + for (const row of data.rows) { + const tr = el("tr", row.is_group ? "is-group" : ""); + const leaf = !row.is_group && row.in_bill && row.progress_current_quantity !== undefined; + tr.append( + el("td", "", row.item_no), + el("td", "", row.name), + el("td", "", row.spec ?? ""), + el("td", "", row.unit ?? ""), + el("td", "num", leaf ? (row.quantity ?? "") : ""), + el("td", "num", won(row.contract_amount_krw)), + el("td", "num", leaf ? String(row.progress_previous_quantity) : ""), + el("td", "num", won(row.progress_previous_amount_krw)), + ); + const cell = el("td"); + if (leaf && current) { + const qty = el("input"); + qty.type = "number"; + qty.min = "0"; + qty.step = "any"; + qty.value = current.quantities[row.item_no] ?? ""; + qty.addEventListener("input", () => { + if (qty.value === "") delete current.quantities[row.item_no]; + else current.quantities[row.item_no] = qty.value; + }); + cell.append(qty); + } + // 사정 — 뜻 미확인(「기성내역서(사정)」 열 이름뿐) · 계산에 안 씀. + const assessed = el("td"); + if (leaf && current) { + const amount = el("input"); + amount.type = "number"; + amount.min = "0"; + amount.title = "사정 — 뜻 확인 대기 · 금액에 안 섞임"; + amount.value = current.assessed[row.item_no] ?? ""; + amount.addEventListener("input", () => { + if (amount.value === "") delete current.assessed[row.item_no]; + else current.assessed[row.item_no] = amount.value; + }); + assessed.append(amount); + } + tr.append( + cell, + el("td", "num", won(row.progress_current_amount_krw)), + assessed, + el("td", "num", leaf ? String(row.progress_cumulative_quantity) : ""), + el("td", "num", won(row.progress_cumulative_amount_krw)), + el("td", "num", pct(row.progress_pct)), + el("td", "num", row.progress_remaining_quantity ?? ""), + el("td", "", row.progress_note ?? ""), + ); + table.append(tr); + } + box.append(table); + return box; +} + +function drawBody(ctx: B09TabContext, data: ProgressDto): void { + const draft = draftOf(ctx.projectId as string, data); + const wrap = el("div", "b09pg"); + wrap.append(el("div", "b09pg__warn", `⚠ ${data.limit_note}`)); + for (const note of data.notes) wrap.append(el("div", "b09pg__warn", `⚠ ${note}`)); + wrap.append(el("div", "b09pg__meta", "금회 기성수량을 넣고 [저장]하면 반영")); + const scroll = el("div", "b09pg__scroll"); + scroll.append(drawJab(data, draft.overrides), drawBill(data, draft.rounds)); + wrap.append(scroll); + ctx.body.append(wrap); +} + +function render(ctx: B09TabContext): void { + injectStyles(); + if (!ctx.projectId) { + ctx.body.append(el("div", "b09pg__meta", "프로젝트를 고르세요")); + return; + } + const load = (round: number | null): void => { + ctx.body.replaceChildren( + el("div", "b09pg__meta", `${L("B09_Estimation_Tab_Progress")} 계산 중…`), + ); + ctx.panel.replaceChildren(); + fetchProgress(ctx.projectId as string, round) + .then((data) => { + const draft = drafts.get(ctx.projectId as string); + if (draft) draft.round = data.round; + ctx.body.replaceChildren(); + drawPanel(ctx, data, load); + drawBody(ctx, data); + }) + .catch((error: unknown) => { + ctx.body.replaceChildren( + el( + "div", + "b09pg__warn", + `기성을 세우지 못함 — ${error instanceof Error ? error.message : ""}`, + ), + ); + }); + }; + load(drafts.get(ctx.projectId)?.round ?? null); +} + +export const progressTab: B09Tab = { + key: "progress", + label: () => L("B09_Estimation_Tab_Progress"), + render, +}; diff --git a/B09_Estimation/B09_Estimation_UI_Tab_RateTable.ts b/B09_Estimation/B09_Estimation_UI_Tab_RateTable.ts new file mode 100644 index 00000000..c6697550 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tab_RateTable.ts @@ -0,0 +1,442 @@ +/* ============================================================================= + * B09_Estimation_UI_Tab_RateTable.ts + * 제비율 요율표 탭 — STmate 「원가계산기준」(wM_KAN_RATE)을 본뜸 (PLAN 12장 · 랩탑 메인). + * + * - 좌측 = 도구줄 자리: `수정기준선택 ☞` [기본제비율] · `수 정` · `취 소` · `저 장`. + * - 본문 = 첫 탭 「☞ 공사원가계산 제잡비율」 묶음 차례 · 아래 탭 넷(첫 탭만 켜짐). + * - 요율 덮어쓰기 = **이 프로젝트만** · 칸마다 사유 필수 · 「기본값 ↔ 고친 값」 갈라 보임 · ↺ 되돌리기. + * - ⚠ 값은 서버가 냄 — 화면은 칸 주소와 새 값·사유만 돌려줌. 마스터 요율표는 안 건드림. + * ========================================================================== */ + +import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, showToast } from "@ui/ui_template_elements"; +import { API_BASE_URL } from "@config/config_frontend"; +import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +interface RateAddress { + variable: string; + table: string | null; + match: Record; +} + +interface RateCell { + value: number | null; + default: number | null; + overridden: boolean; + address: RateAddress; +} + +interface RateSection { + title: string; + formula: string; + columns: string[]; + rows: (string | number | RateCell)[][]; +} + +interface Override extends RateAddress { + rate_percent: string; + reason: string; + entered_at?: string; +} + +interface RateTableDto { + status: string; + message?: string; + rate_version: { dataset_id: string; effective_date: string; sha256: string }; + sections: RateSection[]; + overrides: Override[]; + override_error: string; +} + +/** 고치는 중인 칸 — 저장 전 캐시(지침 5장). */ +interface DraftEntry { + address: RateAddress; + value: string; + reason: string; + label: string; + default: number | null; +} + +/** DFM 아래 탭 — 화면에 보이는 묶음 제목 그대로. */ +const RATE_TABS = [ + "☞ 공사원가계산 제잡비율", + "표준시장단가 제잡비율", + "☞ 행정자치부 적용 제잡비율", + "☞ 실적공사비 적용시 제경비율 조정계수", +]; + +const STYLE_ID = "b09-rate-table-styles"; +function injectStyles(): void { + if (document.getElementById(STYLE_ID)) return; + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = ` +.b09rt { display: flex; flex-direction: column; gap: 8px; height: 100%; min-height: 0; } +.b09rt__meta { font-size: 12px; color: var(--color-text-secondary); } +.b09rt__warn { font-size: 12px; color: var(--color-warning-text, #8a5a00); } +.b09rt__scroll { flex: 1; overflow: auto; min-height: 0; display: flex; flex-direction: column; gap: 12px; } +.b09rt__section { border: 1px solid var(--color-border); padding: 6px 8px; } +.b09rt__title { font-weight: 600; font-size: 13px; } +.b09rt__formula { font-size: 12px; color: var(--color-text-secondary); margin-left: 6px; font-weight: normal; } +.b09rt__table { border-collapse: collapse; font-size: 12px; margin-top: 4px; } +.b09rt__table th, .b09rt__table td { border: 1px solid var(--color-border); padding: 2px 8px; } +.b09rt__table td { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } +.b09rt__table td.b09rt__text { text-align: left; } +.b09rt__table td.is-changed { outline: 2px solid var(--color-accent, #6c8ebf); outline-offset: -2px; } +.b09rt__default { font-size: 11px; color: var(--color-text-secondary); margin-left: 4px; } +.b09rt__table input { width: 5em; text-align: right; } +.b09rt__tabs { display: flex; gap: 2px; border-top: 1px solid var(--color-border); padding-top: 4px; } +.b09rt__tab { font-size: 12px; padding: 2px 10px; border: 1px solid var(--color-border); border-top: none; background: none; } +.b09rt__tab[aria-selected="true"] { font-weight: 600; } +.b09rt__panel { display: flex; flex-direction: column; gap: 6px; font-size: 12px; } +.b09rt__change { display: flex; flex-direction: column; gap: 2px; border-top: 1px dotted var(--color-border); padding-top: 4px; } +.b09rt__change input { width: 100%; } +.b09rt__change input.is-empty { outline: 2px solid var(--color-danger, #d9534f); } +.b09rt__buttons { display: flex; gap: 6px; flex-wrap: wrap; } +`; + document.head.append(style); +} + +function el( + tag: K, + className = "", + text = "", +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + if (className) node.className = className; + if (text) node.textContent = text; + return node; +} + +function addressKey(address: RateAddress): string { + const match = Object.keys(address.match) + .sort() + .map((k) => `${k}=${address.match[k]}`) + .join("&"); + return `${address.variable}|${address.table ?? ""}|${match}`; +} + +function isCell(value: unknown): value is RateCell { + return typeof value === "object" && value !== null && "address" in value; +} + +async function fetchRates(projectId: string): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/rate-table`, + { credentials: "include" }, + ); + const body = (await response.json()) as RateTableDto; + if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`); + return body; +} + +async function saveOverrides(projectId: string, overrides: Override[]): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/rate-table`, + { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ overrides }), + }, + ); + const body = (await response.json()) as { message?: string }; + if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`); +} + +/** 프로젝트별 탭 상태 — 탭을 다시 골라도 [저장] 전 고친 칸이 남음. */ +const states = new Map }>(); + +function stateOf(projectId: string, data: RateTableDto) { + let state = states.get(projectId); + if (!state) { + state = { editing: false, draft: new Map() }; + states.set(projectId, state); + } + if (!state.editing) { + // 편집 중이 아니면 저장본으로 다시 채움 — 저장본이 곧 정본. + state.draft = new Map( + data.overrides.map((o) => [ + addressKey(o), + { + address: { variable: o.variable, table: o.table, match: o.match }, + value: o.rate_percent, + reason: o.reason, + label: "", + default: null, + }, + ]), + ); + } + return state; +} + +function render(ctx: B09TabContext): void { + injectStyles(); + if (!ctx.projectId) { + ctx.body.append(el("div", "b09rt__meta", "프로젝트를 고르세요")); + return; + } + const projectId = ctx.projectId; + const load = (): void => { + ctx.body.replaceChildren(el("div", "b09rt__meta", "요율표 받는 중…")); + fetchRates(projectId) + .then((data) => { + const state = stateOf(projectId, data); + const redraw = (): void => { + ctx.body.replaceChildren(); + ctx.panel.replaceChildren(); + drawBody(ctx, data, state, redraw); + drawPanel(ctx, state, redraw, load); + }; + redraw(); + }) + .catch((error: unknown) => { + ctx.body.replaceChildren( + el( + "div", + "b09rt__meta", + `요율표를 받지 못함 — ${error instanceof Error ? error.message : ""}`, + ), + ); + }); + }; + load(); +} + +function drawPanel( + ctx: B09TabContext, + state: { editing: boolean; draft: Map }, + redraw: () => void, + reload: () => void, +): void { + const box = el("div", "b09rt__panel"); + const pick = el("label", "b09rt__panel"); + pick.append(el("span", "", "수정기준선택 ☞")); + const select = el("select"); + for (const [i, name] of ["기본제비율", "표준시장", "행자부기준"].entries()) { + const option = el("option", "", i ? `${name} (준비 중)` : name); + option.disabled = i > 0; + select.append(option); + } + pick.append(select); + box.append(pick); + + const buttons = el("div", "b09rt__buttons"); + if (!state.editing) { + buttons.append( + createButton({ + label: "수 정", + onClick: () => { + state.editing = true; + redraw(); + }, + }), + ); + } else { + buttons.append( + createButton({ + label: "취 소", + variant: "ghost", + onClick: () => { + state.editing = false; + reload(); + }, + }), + createButton({ + label: "저 장", + onClick: async () => { + const entries = [...state.draft.values()]; + if (entries.some((entry) => !entry.reason.trim())) { + showToast("고친 요율마다 사유를 적어야 저장됨", "error"); + redraw(); + return; + } + try { + await saveOverrides( + ctx.projectId as string, + entries.map((entry) => ({ + ...entry.address, + rate_percent: entry.value, + reason: entry.reason.trim(), + })), + ); + state.editing = false; + showToast("요율 덮어쓰기 저장 — 이 프로젝트만", "success"); + reload(); + } catch (error) { + showToast(error instanceof Error ? error.message : "저장 못 함", "error"); + } + }, + }), + ); + } + box.append(buttons); + box.append( + el( + "span", + "b09rt__meta", + "고친 요율은 이 프로젝트에만 걸림 — 마스터 요율표(조달청 판)는 그대로 · 칸마다 사유 필수", + ), + ); + + // 고친 칸 목록 — 사유 칸(편집 중에만 고침). + const changes = [...state.draft.values()]; + box.append(el("div", "b09rt__title", `고친 요율 ${changes.length}건`)); + for (const entry of changes) { + const row = el("div", "b09rt__change"); + row.append( + el( + "span", + "", + `${entry.label || entry.address.variable} — ${entry.default ?? "?"} → ${entry.value}`, + ), + ); + if (state.editing) { + const reason = el("input"); + reason.placeholder = "사유(필수) — 예: 발주처 지침 ○○호"; + reason.value = entry.reason; + reason.classList.toggle("is-empty", !entry.reason.trim()); + reason.addEventListener("input", () => { + entry.reason = reason.value; + reason.classList.toggle("is-empty", !reason.value.trim()); + }); + row.append(reason); + } else { + row.append(el("span", "b09rt__meta", `사유: ${entry.reason}`)); + } + box.append(row); + } + box.append( + createButton({ label: "원가계산서", variant: "ghost", onClick: () => ctx.open("cost_sheet") }), + ); + ctx.panel.append(box); +} + +function drawBody( + ctx: B09TabContext, + data: RateTableDto, + state: { editing: boolean; draft: Map }, + redraw: () => void, +): void { + const wrap = el("div", "b09rt"); + wrap.append( + el( + "div", + "b09rt__meta", + `원가계산기준 — 요율 판 ${data.rate_version.effective_date} (${data.rate_version.dataset_id})` + + (state.editing ? " · 수정 중" : ""), + ), + ); + if (data.override_error) { + wrap.append( + el("div", "b09rt__warn", `⚠ 저장된 덮어쓰기가 지금 판과 안 맞음 — ${data.override_error}`), + ); + } + const scroll = el("div", "b09rt__scroll"); + for (const section of data.sections) { + const box = el("div", "b09rt__section"); + const title = el("div", "b09rt__title", section.title); + if (section.formula) title.append(el("span", "b09rt__formula", section.formula)); + box.append(title); + const table = el("table", "b09rt__table"); + const head = el("tr"); + for (const column of section.columns) head.append(el("th", "", column)); + table.append(head); + for (const row of section.rows) { + const tr = el("tr"); + const rowLabel = row.filter((c) => typeof c === "string" && c).join(" "); + row.forEach((cell, index) => { + if (!isCell(cell)) { + tr.append(el("td", typeof cell === "number" ? "" : "b09rt__text", String(cell))); + return; + } + const key = addressKey(cell.address); + const entry = state.draft.get(key); + if (entry) { + entry.label = `${section.title} · ${rowLabel} · ${section.columns[index]}`; + entry.default = cell.default; + } + tr.append(rateCell(cell, entry, state, section, rowLabel, index, redraw)); + }); + table.append(tr); + } + box.append(table); + scroll.append(box); + } + wrap.append(scroll); + const tabs = el("div", "b09rt__tabs"); + RATE_TABS.forEach((name, i) => { + const tab = el("button", "b09rt__tab", name); + tab.setAttribute("aria-selected", String(i === 0)); + tab.disabled = i > 0; + if (i > 0) tab.title = "준비 중"; + tabs.append(tab); + }); + wrap.append(tabs); + ctx.body.append(wrap); +} + +/** 요율 칸 — 보기: 값(+기본값) · 편집: 입력 + ↺. 기본값과 같아지면 덮어쓰기에서 빠짐. */ +function rateCell( + cell: RateCell, + entry: DraftEntry | undefined, + state: { editing: boolean; draft: Map }, + section: RateSection, + rowLabel: string, + index: number, + redraw: () => void, +): HTMLTableCellElement { + const td = el("td"); + const key = addressKey(cell.address); + const shown = entry ? entry.value : String(cell.value ?? ""); + const changed = Boolean(entry) && Number(shown) !== cell.default; + td.classList.toggle("is-changed", changed); + if (entry?.reason) td.title = `기본 ${cell.default} · 사유: ${entry.reason}`; + if (!state.editing) { + td.append(document.createTextNode(shown)); + if (changed) td.append(el("span", "b09rt__default", `(기본 ${cell.default})`)); + return td; + } + const input = el("input"); + input.type = "number"; + input.step = "0.001"; + input.min = "0"; + input.value = shown; + input.addEventListener("change", () => { + if (input.value === "" || Number(input.value) === cell.default) { + state.draft.delete(key); + } else { + state.draft.set(key, { + address: cell.address, + value: input.value, + reason: entry?.reason ?? "", + label: `${section.title} · ${rowLabel} · ${section.columns[index]}`, + default: cell.default, + }); + } + redraw(); + }); + td.append(input); + if (entry) { + const undo = el("button", "", "↺"); + undo.type = "button"; + undo.title = `기본값(${cell.default})으로 — [저 장]하면 덮어쓰기에서 빠짐`; + undo.addEventListener("click", () => { + state.draft.delete(key); + redraw(); + }); + td.append(undo); + } + return td; +} + +export const rateTableTab: B09Tab = { + key: "rate_table", + label: () => L("B09_Estimation_Tab_RateTable"), + render, +}; diff --git a/B09_Estimation/B09_Estimation_UI_Tab_Summary.ts b/B09_Estimation/B09_Estimation_UI_Tab_Summary.ts new file mode 100644 index 00000000..b30599ca --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tab_Summary.ts @@ -0,0 +1,118 @@ +/* ============================================================================= + * B09_Estimation_UI_Tab_Summary.ts + * B09 집계표 탭 — 재료비·노무비·경비 수량금액집계표 + 중기시간금액집계표 (실무 시트 · PLAN 12장) + * + * - 칸은 실무 시트 그대로: + * 재료비·노무비·경비 호표 · 명칭 · 규격 · 수량 · 단위 · 단가 · 금액 · 비고 + * 중기 호표 · 명칭 · 규격 · 수량 · 단위 · 합계 · 노무비 · 재료비 · 경비 · 비고 (⚠ 단가 열 없음) + * - 값은 서버가 내역에서 되모은 것(`resources`) — 집계표는 **반올림**이라 내역서 본체(절사)와 원 단위로 + * 어긋나는 것이 정상(그 문구를 표 아래에 함께 보임). + * - 줄을 누르면 중기는 그 기종의 중기사용료로 들어감. + * ========================================================================== */ + +import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types"; +import { + L, + el, + hint, + numberCell, + plainTable, + quantity, + segmented, + won, +} from "./B09_Estimation_UI_Sheet"; +import { loadBill, type BillDto, type ResourceGroup } from "./B09_Estimation_UI_Store"; + +let group: ResourceGroup = "material"; + +const GROUPS: Array<[ResourceGroup, Parameters[0]]> = [ + ["material", "B09_Sheet_Sum_Material"], + ["labor", "B09_Sheet_Sum_Labor"], + ["expense", "B09_Sheet_Sum_Expense"], + ["machine", "B09_Sheet_Sum_Machine"], +]; + +function draw(ctx: B09TabContext, bill: BillDto): void { + ctx.body.append( + segmented( + GROUPS.map(([key, label]) => [key, L(label)]), + group, + (key) => { + group = key as ResourceGroup; + ctx.body.replaceChildren(); + draw(ctx, bill); + }, + ), + ); + const machine = group === "machine"; + const front = [ + L("B09_Sheet_Col_Sheet"), + L("B09_Sheet_Col_Name"), + L("B09_Sheet_Col_Spec"), + L("B09_Sheet_Col_Quantity"), + L("B09_Sheet_Col_Unit"), + ]; + const money = machine + ? [ + L("B09_Sheet_Col_Total"), + L("B09_Sheet_Col_Labor"), + L("B09_Sheet_Col_Material"), + L("B09_Sheet_Col_Expense"), + ] + : [L("B09_Sheet_Col_UnitPrice"), L("B09_Sheet_Col_Amount")]; + const { wrap, tbody } = plainTable([...front, ...money, L("B09_Sheet_Col_Note")]); + const rows = bill.resources.groups[group]; + for (const row of rows) { + const tr = el("tr"); + tr.append( + el("td", "", String(row.number)), + el("td", "", row.name), + el("td", "", row.spec), + numberCell(quantity(row.quantity, null)), + el("td", "", row.unit), + ); + if (machine) { + tr.append( + numberCell(won(row.amount_krw)), + numberCell(won(row.labor_krw)), + numberCell(won(row.material_krw)), + numberCell(won(row.expense_krw)), + ); + tr.classList.add("is-clickable"); + tr.title = L("B09_Sheet_Drill"); + tr.addEventListener("click", () => ctx.open("machine", row.code)); + } else { + tr.append(numberCell(won(row.unit_price_krw)), numberCell(won(row.amount_krw))); + } + tr.append(el("td", "b09s-note", row.note)); + tbody.append(tr); + } + ctx.body.append(wrap); + if (rows.length === 0) ctx.body.append(hint(L("B09_Sheet_EmptyGroup"))); + ctx.body.append(hint(bill.resources.note)); + if (bill.resources.missing.length > 0) { + ctx.body.append( + hint(`${L("B09_Sheet_SumMissing")} ${bill.resources.missing.join(", ")}`, true), + ); + } +} + +export const summaryTab: B09Tab = { + key: "summary", + label: () => L("B09_Sheet_Tab_Summary"), + render(ctx) { + if (!ctx.projectId) { + ctx.body.append(hint(L("B09_Sheet_NoProject"))); + return; + } + ctx.body.append(hint(L("B09_Sheet_Loading"))); + loadBill(ctx.projectId) + .then((bill) => { + ctx.body.replaceChildren(); + draw(ctx, bill); + }) + .catch((error: Error) => { + ctx.body.replaceChildren(hint(`${L("B09_Sheet_LoadFailed")} ${error.message}`, true)); + }); + }, +}; diff --git a/B09_Estimation/B09_Estimation_UI_Tab_Supply.ts b/B09_Estimation/B09_Estimation_UI_Tab_Supply.ts new file mode 100644 index 00000000..01fea936 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tab_Supply.ts @@ -0,0 +1,85 @@ +/* ============================================================================= + * B09_Estimation_UI_Tab_Supply.ts + * B09 관급·사급 탭 — 자재대: B08 수량·할증에 단가를 붙인 표 (PLAN 8-7 「금액은 B09」) + * + * - 옛 `B09_Estimation_UI_Page.ts` 의 자재대 탭을 그대로 옮김(PLAN 12장 옛 탭 옮기기). + * - 사급(도급 재료비) · 관급(총원가 밖 별도 표기) · 안 갈린 것(어느 합계에도 안 넣음) 세 무리. + * ⚠ 「안 갈린 것」은 못 세운 것이 아니라 **세면 안 되는 것** — 채우면 이중계상(PLAN 8-36 ㉱). + * - 자료는 설계내역서 응답 한 벌(`summary.material_sheet`) — 내역 탭들과 같은 응답을 봄. + * ========================================================================== */ + +import type { ui_locales } from "@ui/ui_template_locale"; +import type { B09Tab } from "./B09_Estimation_UI_Shell_Types"; +import { L, hint, quantity } from "./B09_Estimation_UI_Sheet"; +import { infoTable, note } from "./B09_Estimation_UI_Table"; +import { loadBill, type BillDto, type MaterialSheetRowDto } from "./B09_Estimation_UI_Store"; + +const MATERIAL_KEYS = [ + "name", + "spec", + "unit", + "total_amount", + "unit_price_krw", + "amount_krw", + "note", +]; + +function draw(body: HTMLElement, bill: BillDto): void { + const sheet = bill.summary.material_sheet; + if (!sheet) { + body.append(hint(L("B09_Estimation_Mat_Empty"))); + return; + } + // 자재가 아예 없으면 **빈 표 셋을 늘어놓지 않음** — 「없다」 한 줄이면 됨(2026-09-08 화면 전수). + if (sheet.contractor.length === 0 && sheet.owner.length === 0 && sheet.unknown.length === 0) { + body.append(hint(L("B09_Estimation_Mat_None"))); + return; + } + const groups: Array<[keyof typeof ui_locales, MaterialSheetRowDto[], string | null, string]> = [ + ["B09_Estimation_Mat_Contractor", sheet.contractor, sheet.contractor_total_krw, "material"], + ["B09_Estimation_Mat_Owner", sheet.owner, sheet.owner_total_krw, "material"], + ["B09_Estimation_Mat_Unknown", sheet.unknown, null, "material_unknown"], + ]; + for (const [labelKey, rows, total, sheetName] of groups) { + body.append(note(`${L(labelKey)} (${rows.length})` + (total === null ? "" : ` — ${total}`))); + if (rows.length === 0) continue; + body.append( + infoTable( + ["자재", "규격", "단위", "수량", "단가", "금액", "비고"], + rows.map((row) => [ + row.name, + row.spec, + row.unit, + quantity(row.total_amount, 2), + row.unit_price_krw ?? "", + row.amount_krw ?? "", + row.note, + ]), + [0, 1, 2, 6], + MATERIAL_KEYS, + bill.provenance?.sheets?.[sheetName], + ), + ); + } + for (const line of sheet.notes) body.append(note(line.replace(/\*\*/g, ""))); +} + +export const supplyTab: B09Tab = { + key: "supply", + label: () => L("B09_Estimation_Tab_Supply"), + render(ctx) { + if (!ctx.projectId) { + ctx.body.append(hint(L("B09_Sheet_NoProject"))); + return; + } + ctx.body.append(hint(L("B09_Sheet_Loading"))); + loadBill(ctx.projectId) + .then((bill) => { + ctx.body.replaceChildren(); + draw(ctx.body, bill); + }) + .catch((error: Error) => { + ctx.body.replaceChildren(hint(`${L("B09_Sheet_LoadFailed")} ${error.message}`, true)); + }); + }, +}; diff --git a/B09_Estimation/B09_Estimation_UI_Tab_UnitPrice.ts b/B09_Estimation/B09_Estimation_UI_Tab_UnitPrice.ts new file mode 100644 index 00000000..7eacc813 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tab_UnitPrice.ts @@ -0,0 +1,126 @@ +/* ============================================================================= + * B09_Estimation_UI_Tab_UnitPrice.ts + * B09 일위대가 탭 — 목록표 + 일위대가표 (STmate `일위대가목록표` · `wM_Edit_iLWi` 보기 · PLAN 12장 1차) + * + * - 목록표 = 내역에 쓰인 호표만, **내역에 처음 쓰인 차례**로 「제 N 호표」(번호는 서버가 낼 때마다 매김). + * - 줄을 누르면 아래에 그 호표의 일위대가표 — 호표 안 줄(하위 호표·단산·중기)을 누르면 더 들어감. + * - 구조물도 호표는 B08 구조물도 탭의 일위대가 표가 본표 — 여기선 금액·안내만. + * - 편집(구성행 수정)은 2차 — B09 에 수정 저장 자리가 아직 없음(브레인 판정). + * ========================================================================== */ + +import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types"; +import { renderDetail } from "./B09_Estimation_UI_Detail"; +import { + L, + el, + hint, + numberCell, + sheetTable, + unconfirmedBadge, + won, +} from "./B09_Estimation_UI_Sheet"; +import { loadBill, type BillDto, type SheetEntryDto } from "./B09_Estimation_UI_Store"; + +let selected = ""; + +/** 목록표 머리 — 호표 · 명칭 · 규격 · 단위 · 합계 · 노무비 · 재료비 · 경비 · 비고(실무 목록표). */ +export function listHead(first: string): HTMLElement { + const thead = el("thead"); + const tr = el("tr"); + for (const label of [ + first, + L("B09_Sheet_Col_Name"), + L("B09_Sheet_Col_Spec"), + L("B09_Sheet_Col_Unit"), + L("B09_Sheet_Col_Total"), + L("B09_Sheet_Col_Labor"), + L("B09_Sheet_Col_Material"), + L("B09_Sheet_Col_Expense"), + L("B09_Sheet_Col_Note"), + ]) { + tr.append(el("th", "", label)); + } + thead.append(tr); + return thead; +} + +export function listRow(entry: SheetEntryDto, total: string, isSelected: boolean): HTMLElement { + const tr = el("tr", `is-clickable${isSelected ? " is-selected" : ""}`); + tr.append( + el("td", "", entry.label), + el("td", "", entry.name), + el("td", "", entry.spec), + el("td", "", entry.unit), + numberCell(won(total)), + numberCell(won(entry.labor_krw)), + numberCell(won(entry.material_krw)), + numberCell(won(entry.expense_krw)), + ); + const note = el("td", "b09s-note"); + if (entry.unconfirmed) { + note.append(unconfirmedBadge(entry.unconfirmed)); + tr.classList.add("is-manual"); + } + tr.append(note); + return tr; +} + +function labelFor(bill: BillDto, code: string): string { + const entry = bill.unit_price_sheet.entries.find((item) => item.code === code); + if (entry) return entry.label; + return code.startsWith("X-") ? `${L("B09_Sheet_Machine")} ${code.slice(2)}` : code; +} + +function draw(ctx: B09TabContext, bill: BillDto): void { + const entries = bill.unit_price_sheet.entries; + const page = el("div", "b09s-split"); + const { wrap, tbody } = sheetTable(listHead(L("B09_Sheet_Col_Sheet"))); + const detail = el("div", "b09s-split"); + const select = (code: string): void => { + selected = code; + ctx.body.replaceChildren(); + draw(ctx, bill); + }; + for (const entry of entries) { + const tr = listRow(entry, entry.total_krw ?? "", entry.code === selected); + tr.addEventListener("click", () => select(entry.code)); + tbody.append(tr); + } + page.append(el("div", "b09s-title", L("B09_Sheet_UnitPriceList")), wrap); + if (entries.length === 0) page.append(hint(L("B09_Sheet_EmptyList"))); + page.append(detail); + ctx.body.append(page); + + if (!selected) { + detail.append(hint(L("B09_Sheet_PickSheet"))); + return; + } + const picked = entries.find((entry) => entry.code === selected); + if (picked?.source === "structure") { + detail.append(el("div", "b09s-title", `${picked.label} ${picked.name} · ${picked.spec}`)); + detail.append(hint(L("B09_Sheet_StructureSheet"))); + return; + } + renderDetail(ctx, detail, "unit_price", selected, labelFor(bill, selected)); +} + +export const unitPriceTab: B09Tab = { + key: "unit_price", + label: () => L("B09_Estimation_Tab_UnitPrice"), + render(ctx, arg) { + if (!ctx.projectId) { + ctx.body.append(hint(L("B09_Sheet_NoProject"))); + return; + } + if (arg) selected = arg; + ctx.body.append(hint(L("B09_Sheet_Loading"))); + loadBill(ctx.projectId) + .then((bill) => { + ctx.body.replaceChildren(); + draw(ctx, bill); + }) + .catch((error: Error) => { + ctx.body.replaceChildren(hint(`${L("B09_Sheet_LoadFailed")} ${error.message}`, true)); + }); + }, +}; diff --git a/B09_Estimation/B09_Estimation_UI_Table.ts b/B09_Estimation/B09_Estimation_UI_Table.ts new file mode 100644 index 00000000..4ead614c --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Table.ts @@ -0,0 +1,66 @@ +/* ============================================================================= + * B09_Estimation_UI_Table.ts + * 옛 B09 화면에서 옮겨 온 **설명 표** 공용 — 머리글 한 줄 · 제목 · 안내 글 · 근거 호버 (PLAN 12장) + * + * - 옛 `B09_Estimation_UI_BaseData.ts` 의 `head`·`note`·`table` 을 그대로 옮김(옛 파일을 지우려고). + * - 칸마다 근거 호버(`keys`·`sheet`) — 사전은 **개발환경에서만** 오고, 없으면 아무 일도 안 함. + * - 숫자 칸은 오른쪽, `leftCols` 칸은 왼쪽(옛 `.b09-left` 와 같음). + * ========================================================================== */ + +import { + attachProvenance, + markProvenanceCell, + type ProvenanceSheet, +} from "@ui/ui_template_provenance"; +import { el } from "./B09_Estimation_UI_Sheet"; + +/** 금액 글 → 천 단위 쉼표(옛 `money` 그대로 — 소수부를 자르지 않음). */ +export function money(value: string | null | undefined): string { + if (value === null || value === undefined || value === "") return ""; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed.toLocaleString("ko-KR") : value; +} + +/** 구역 제목. */ +export function head(text: string): HTMLElement { + return el("div", "b09s-hint b09s-head", text); +} + +/** 안내 한 줄. */ +export function note(text: string): HTMLElement { + return el("div", "b09s-hint", text); +} + +/** 설명 표 한 장 — 가로로 넘치면 제 칸 안에서만 밀림. */ +export function infoTable( + headers: string[], + rows: string[][], + leftCols: number[], + keys?: string[], + sheet?: ProvenanceSheet, +): HTMLElement { + const wrap = el("div", "b09s-wrap"); + const table = el("table", "b09s-table b09s-info"); + const thead = el("thead"); + const headRow = el("tr"); + headers.forEach((text, index) => { + headRow.append(el("th", leftCols.includes(index) ? "b09s-left" : "", text)); + }); + thead.append(headRow); + const tbody = el("tbody"); + for (const cells of rows) { + const tr = el("tr"); + cells.forEach((text, index) => { + const td = el("td", leftCols.includes(index) ? "b09s-left" : "", text); + const key = keys?.[index]; + const column = key ? sheet?.columns[key] : undefined; + if (key && column) markProvenanceCell(td, key, column.tier); + tr.append(td); + }); + tbody.append(tr); + } + table.append(thead, tbody); + attachProvenance(table, sheet); + wrap.append(table); + return wrap; +} diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index fe80c425..4d5570d3 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -65,6 +65,7 @@ from B09_Estimation.B09_Estimation_ResourceAxis import ( load_labor_catalog, load_work_item_master, ) +from B09_Estimation.B09_Estimation_WorkItemUnit import BORROWED_BASIS_PER from B09_Estimation.B09_Estimation_WorkItemUnit import unit_of as work_item_unit from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at from B09_Estimation.B09_Estimation_Transport import parse_distance_km @@ -141,6 +142,14 @@ class UnitPriceBuild: parent_modes: dict[str, str] = field(default_factory=dict) #: 합산형 부모 → (갈래, 근거) — 내역 줄의 갈래가 표에 없을 때 **원문이 정한** 갈래(9-4-1 [주]① 평균). default_variants: dict[str, tuple[str, str]] = field(default_factory=dict) + #: 갈래가 **어디서 왔나** — 표가 아니라 **식이 낸** 갈래 제목 → 그 식(2026-09-14 브레인 판정). + #: 표에서 온 갈래는 마스터 갈래 키에 있어야 하고(짝 시험), 식이 낸 갈래는 여기 적혀 있어야 함 + #: (덤프 운반·적재 10-12 `#L…m`·`#적재` — 본문 식이라 마스터 표에 갈래가 없음). + formula_variants: dict[str, str] = field(default_factory=dict) + #: 수동 단가로 선 자재 제목 → 출처 — 내역 줄 「미확정 N건」(PLAN 1장 Ⓐ). + manual_materials: dict[str, str] = field(default_factory=dict) + #: 자원 축 자재 코드 → 쓰는 공종들 — 「자재 단가」 탭이 단가 칸을 낼 줄. + material_uses: dict[str, list[str]] = field(default_factory=dict) def _add_labor_titles(book: PriceBook, wages: dict[str, Decimal]) -> None: @@ -617,6 +626,8 @@ def build_unit_prices( transport_road: str | None = None, labor_surcharge_choices: dict[str, str] | None = None, operator_wage_digits: int = 0, + dump_haul_m: tuple[Decimal, ...] = (), + material_prices: tuple[tuple[str, str, str], ...] = (), ) -> UnitPriceBuild: """자원 축을 일위대가(`B`)로 조립한다. @@ -771,10 +782,40 @@ def build_unit_prices( from B09_Estimation.B09_Estimation_Transport import TRANSPORT_VARIANTS machine_codes |= {variant["machine_code"] for variant in TRANSPORT_VARIANTS} + # 덤프 운반(10-12)도 공식표가 아니라 본문 식이라 기종이 안 드러남 — **거리가 왔을 때만** 세운다. + from B09_Estimation.B09_Estimation_MachineProductivity_Dump import ( + DUMP_TRUCK_CODE, + attach_dump_hauls, + ) + + if dump_haul_m: + machine_codes.add(DUMP_TRUCK_CODE) build.operator_wage_digits = operator_wage_digits build.incomplete_machines = _add_machine_layers( build.book, machine_codes, fuel_region, operator_wage_digits ) + # 자재 수동 단가(PLAN 1장 Ⓐ) — 코드 키만 제목으로 섬. 안 넣으면 자재 줄은 종전처럼 안 붙음. + from B09_Estimation.B09_Estimation_MaterialPrices import add_material_titles + + build.manual_materials = add_material_titles(build.book, material_prices) + from B09_Estimation.B09_Estimation_Chisel import ( + BREAKER_PREFIX, + CHISEL_CODE, + attach_chisel, + chisel_per_hour, + without_chisel_labels, + ) + + nodes_by_code = {str(node.get("work_item_code")): node for node in master.get("work_items", [])} + # 치즐은 자원 축 줄이 아니라 표의 소모량 줄 — 「자재 단가」 탭에 칸이 서게 쓰는 공종을 실음. + chisel_items = sorted(code for code, node in nodes_by_code.items() if chisel_per_hour(node)) + if chisel_items: + build.material_uses[CHISEL_CODE] = chisel_items + for row in axis.rows: + if row.resource_kind == "material": + uses = build.material_uses.setdefault(row.resource_code, []) + if row.work_item_code not in uses: + uses.append(row.work_item_code) # 규격 갈래(`variant`)가 있으면 **갈래마다 따로 세운다** — 무근·철근·소형구조물은 # 품이 달라 한 일위대가로 뭉치면 어느 것도 안 맞는다. @@ -821,10 +862,7 @@ def build_unit_prices( # ⚠ **붙을 상세를 먼저 모으고, 하나도 없으면 제목도 안 세운다.** # 제목만 세워 두면 「상세 줄이 없어 단가를 못 조립」하는 빈 일위대가가 남는다 # (기계 층이 안 선 기종만 참조하는 공종에서 실제로 생겼음). - attachable = [ - (row, row.resource_code if row.resource_kind == "labor" else f"X-{row.resource_code}") - for row in rows - ] + attachable = [(row, _ref_of(row)) for row in rows] attachable = [(row, ref) for row, ref in attachable if ref in build.book.titles] # ⚠ **맞췄으나 단가 층이 없는 줄**(카탈로그 밖 `AR-` 자원 · 층을 못 세운 기계)은 조용히 # 빼지 않는다 — 못 맞춘 줄처럼 드러내고, 기계 몫이면 단가를 막는다(2026-09-13 축 C). @@ -852,9 +890,12 @@ def build_unit_prices( continue # ⚠ 밑수를 못 찾은 표를 쓰면 **곱하면 안 되는 줄**로 표시한다. + # 예외 = 사용자가 다른 품셈 밑수를 확정한 자리(9-21 「1,000㎡당」 · ÷ 는 ChooseOne 이 검) — + # 마스터 목록은 원문 사실이라 그대로 두고 여기서만 걷음(2026-09-14 브레인 · 사유는 비고). + borrowed = work_item_code.startswith(tuple(BORROWED_BASIS_PER)) for row in rows: section = missing_basis.get(str(row.pum_table_id)) - if section: + if section and not borrowed: build.basis_missing[work_item_code] = section break @@ -978,8 +1019,20 @@ def build_unit_prices( f" {capacity['capacity_per_hour']} · {capacity['source_text']}" ) ), + output=fix2(capacity["capacity_per_hour"]), ) attached_capacity = True + # 치즐 — 대형브레이커 줄 옆 「치즐소모량(본/hr)」 ÷ Q × 배분(Ⓐ-3 · 수동 단가가 있을 때만). + if capacity["machine_code"].startswith(BREAKER_PREFIX) and attach_chisel( + build.book, + title_code, + chisel_per_hour(nodes_by_code.get(work_item_code, {})), + fix2(capacity["capacity_per_hour"]), + group_share, + ): + build.unattached[work_item_code] = without_chisel_labels( + build.unattached.get(work_item_code, []) + ) # ⚠ **배분율이 있는 표는 「몇 %가 실제로 붙었나」를 세어 둔다.** # 「인력(10%)·장비(90%)」 표에서 인력만 붙으면 단가가 10 % 몫만인데, 그 값이 @@ -1021,6 +1074,8 @@ def build_unit_prices( distance_km=transport_distance_km, road_key=transport_road, ) + # 덤프 운반 — 운반거리(B08 유토곡선·사토장)마다 한 벌(산림품셈 10-12 「2. 운반」). + attach_dump_hauls(build, master, dump_haul_m) # ⚠ **마지막에 한 번** — 조합 사용 공종의 본체 기계를 잡재료 16% 층으로 바꿔 단다 # (품셈 제8장 [주]⑤). 조립 도중에 바꾸면 어느 공종이 조합인지 아직 모른다. @@ -1077,6 +1132,11 @@ def _has_full_formula( ) +def _ref_of(row) -> str: + """자원 줄이 붙을 단가표 제목 — 노임·자재는 코드 그대로, 기계는 시간당 층(`X-`).""" + return f"X-{row.resource_code}" if row.resource_kind == "machine" else row.resource_code + + def _share_of(row) -> Decimal: """그 줄이 차지하는 몫(0~1). 배분율이 없으면 1 — 종전과 같다.""" ratio = getattr(row, "group_ratio_pct", None) @@ -1098,8 +1158,7 @@ def _covered_ratio_pct( ratio = getattr(row, "group_ratio_pct", None) if ratio is None or ratio in seen: continue - ref = row.resource_code if row.resource_kind == "labor" else f"X-{row.resource_code}" - if ref in attached_refs: + if _ref_of(row) in attached_refs: seen.add(ratio) covered += ratio return covered @@ -1160,6 +1219,8 @@ def cached_build( transport_road: str = "", labor_surcharge: tuple[tuple[str, str], ...] = (), operator_wage_digits: str = "", + dump_haul_m: tuple[str, ...] = (), + material_prices: tuple[tuple[str, str, str], ...] = (), ) -> UnitPriceBuild: """조립 결과를 한 번만 만든다 — 품셈 3 MB 를 요청마다 다시 읽지 않는다. @@ -1187,6 +1248,8 @@ def cached_build( transport_road=transport_road or None, labor_surcharge_choices=dict(labor_surcharge), operator_wage_digits=parse_operator_wage_digits(operator_wage_digits), + dump_haul_m=tuple(Decimal(value) for value in dump_haul_m), + material_prices=material_prices, ) diff --git a/B09_Estimation/B09_Estimation_UnitPriceSheet.py b/B09_Estimation/B09_Estimation_UnitPriceSheet.py new file mode 100644 index 00000000..643fccfc --- /dev/null +++ b/B09_Estimation/B09_Estimation_UnitPriceSheet.py @@ -0,0 +1,115 @@ +"""B09 원가계산 — 일위대가표 **호표 번호** (PLAN 12장 · 실무 `일위대가목록표`). + +실무 내역서는 일위대가를 「제 N 호표」로 부르고, 목록표가 번호마다 명칭·규격·성분 금액을 보인다. + +⚠ **번호는 저장하지 않음**(2026-09-14 브레인 판정) — 내역이 바뀌면 번호도 바뀌므로 낼 때마다 매김. + 단산 번호(`B09_Estimation_PriceBasis`)와 같은 규칙: **내역에 처음 쓰인 차례**. +⚠ 내역 줄이 부르지 않고 **다른 일위대가 안에서만** 불리는 호표(찰쌓기 안의 모르타르 배합 따위)는 + 부르는 호표 **바로 뒤**에 번호가 붙음(줄 차례대로 깊이 우선). +⚠ 구조물도 호표(`B-AX-ST-…#키`)는 단가표 제목이 아니라 B08 일위대가 표로 섬 — 금액은 그 표 값, + 안의 하위 호표 번호는 아직 안 매김(구조물도 탭이 그 표를 보임). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from B09_Estimation.B09_Estimation_PriceBook import Money3, PriceBook, PriceKind + +STRUCTURE_SOURCE = "structure" + + +@dataclass +class UnitPriceSheetEntry: + number: int + code: str + name: str + spec: str + unit: str + money: Money3 + #: 「book」 단가표 제목 · 「structure」 구조물도 일위대가 표. + source: str = "book" + unconfirmed: int = 0 + + @property + def label(self) -> str: + return f"제 {self.number} 호표" + + def as_dict(self) -> dict[str, Any]: + return { + "number": self.number, + "label": self.label, + "code": self.code, + "name": self.name, + "spec": self.spec, + "unit": self.unit, + "material_krw": str(self.money.material), + "labor_krw": str(self.money.labor), + "expense_krw": str(self.money.expense), + "total_krw": str(self.money.total), + "source": self.source, + "unconfirmed": self.unconfirmed, + } + + +@dataclass +class UnitPriceSheet: + entries: list[UnitPriceSheetEntry] = field(default_factory=list) + + def as_dict(self) -> dict[str, Any]: + return {"entries": [entry.as_dict() for entry in self.entries]} + + +def _nested(book: PriceBook, code: str, seen: tuple[str, ...]) -> list[str]: + """호표 안에서 부르는 일위대가 — 줄 차례대로 깊이 우선.""" + found: list[str] = [] + for detail in book.details.get(code, []): + ref = detail.ref_code + title = book.titles.get(ref) + if title is None or title.kind is not PriceKind.UNIT_PRICE or ref in seen: + continue + found.append(ref) + found.extend(_nested(book, ref, (*seen, ref))) + return found + + +def build_unit_price_sheet( + rows: list[Any], + book: PriceBook, + structure_prices: dict[str, dict[str, Any]] | None = None, +) -> UnitPriceSheet: + """내역 줄(`BillRow`) 차례 → 호표 번호 한 벌. 같은 호표를 두 줄이 불러도 한 번만.""" + sheet = UnitPriceSheet() + numbered: set[str] = set() + + def add(code: str, **values: Any) -> None: + numbered.add(code) + sheet.entries.append( + UnitPriceSheetEntry(number=len(sheet.entries) + 1, code=code, **values) + ) + + for row in rows: + code = row.price_code + if not code or code in numbered: + continue + structure = (structure_prices or {}).get(code) + if structure is not None: + add( + code, + name=row.name, + spec=row.spec, + unit=row.unit, + money=structure["money"], + source=STRUCTURE_SOURCE, + unconfirmed=int(structure.get("unconfirmed") or 0), + ) + continue + if code not in book.titles: + continue + for ref in (code, *_nested(book, code, (code,))): + if ref in numbered: + continue + title = book.title(ref) + add(ref, name=title.name, spec=title.spec, unit=title.unit, money=book.resolve(ref)) + return sheet diff --git a/B09_Estimation/B09_Estimation_UnitPrice_View.py b/B09_Estimation/B09_Estimation_UnitPrice_View.py index 3d88c18f..c4d6c0be 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice_View.py +++ b/B09_Estimation/B09_Estimation_UnitPrice_View.py @@ -14,7 +14,7 @@ from decimal import Decimal from B09_Estimation.B09_Estimation_MachineOperating import load_fuel_price from B09_Estimation.B09_Estimation_MaterialCatalog import catalog_summary, load_material_catalog -from B09_Estimation.B09_Estimation_PriceBook import PriceKind +from B09_Estimation.B09_Estimation_PriceBook import TRUNCATED_KINDS, PriceKind from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at from B09_Estimation.B09_Estimation_UnitPrice import ( DRILLABLE_KINDS, @@ -200,9 +200,12 @@ def detail_of(build: UnitPriceBuild, code: str) -> dict: "spec": f"노무비의 {detail.percent_of_labor}%", "unit": "%", "quantity": str(detail.percent_of_labor), + # 단가 칸 = 밑수(노무비 합) — 실무 표가 「밑수 × %」를 그 칸에 적음. + "unit_expense": _money_text(labor_so_far), + "unit_total": _money_text(labor_so_far), "material": "0", "labor": "0", - "expense": str(amount), + "expense": _money_text(amount), "total": _money_text(amount), "source": "품셈 [주]", "drillable": False, @@ -233,7 +236,10 @@ def detail_of(build: UnitPriceBuild, code: str) -> dict: "spec": f"{'주연료비' if misc else '주재료비'}의 {detail.percent_of_material}%", "unit": "%", "quantity": str(detail.percent_of_material), - "material": str(amount), + # 단가 칸 = 밑수(주연료비·주재료비) — 실무 중기사용료 「잡품 21 % × 6,365」 꼴. + "unit_material": _money_text(material_so_far), + "unit_total": _money_text(material_so_far), + "material": _money_text(amount), "labor": "0", "expense": "0", "total": _money_text(amount), @@ -285,16 +291,22 @@ def detail_of(build: UnitPriceBuild, code: str) -> dict: # ㉤ 열 방향 검사 — 같은 성분을 두 층에서 세면 여기서 멈춘다. # 행 방향(`TC=NC+GC+JC`)만으로는 안 잡히는 어긋남이다. check_column_sums(rows=rows, totals=summed, label=f"{title.name} 본표") + # 합계 줄 — 호표 안에서 자르는 층(X·D·B)은 **성분 소계 원 미만 절사** 값(실무 표 합계 줄 · 명세 7장). + shown = ( + {"material": money.material, "labor": money.labor, "expense": money.expense} + if title.kind in TRUNCATED_KINDS + else {key: summed[key] for key in ("material", "labor", "expense")} + ) return { "code": code, "name": title.name, "spec": title.spec, "unit": title.unit, "kind": title.kind.value, - "material": str(summed["material"]), - "labor": str(summed["labor"]), - "expense": str(summed["expense"]), - "total": str(summed["total"]), + "material": str(shown["material"]), + "labor": str(shown["labor"]), + "expense": str(shown["expense"]), + "total": str(sum(shown.values(), Decimal(0))), # TC = NC + GC + JC 가 성립하는지 화면이 스스로 보이게 한다. "sum_matches": summed["total"] == summed["material"] + summed["labor"] + summed["expense"], # 전정밀 합과의 차이 — 행별 절사 탓에 끝자리가 어긋나는 것은 **정상**이다. diff --git a/common_util/common_util_cross_structure_areas.ts b/common_util/common_util_cross_structure_areas.ts index f3ec9fc5..3f569edd 100644 --- a/common_util/common_util_cross_structure_areas.ts +++ b/common_util/common_util_cross_structure_areas.ts @@ -72,12 +72,10 @@ function interpolator( }; } -/** - * 구조물이 선 뒤의 절·성토 면적. 트림 바깥은 구조물이 그리는 폴리라인을 따르고, 그 선이 - * 없으면 트림 경계 표고에서 끊어 **지반선에 붙인다**(그 바깥은 손대지 않은 원지반이라 - * 면적이 0이 된다). - */ -export function computeStructureAreas(input: StructureAreaInput): StructureAreaResult | null { +/** 폐회로의 위(실제로 그려지는 설계선)·아래(지반선) 보간기 — 면적과 벽 겹침이 같은 선을 봄. */ +function drawnProfile( + input: StructureAreaInput, +): { drawnZ: (offset: number) => number; ground: (offset: number) => number } | null { const design = interpolator( input.designLine.map((point) => ({ offset: point.offset_m, elevation: point.elevation_m })), ); @@ -117,6 +115,19 @@ export function computeStructureAreas(input: StructureAreaInput): StructureAreaR } return design(offset); }; + return { drawnZ, ground }; +} + +/** + * 구조물이 선 뒤의 절·성토 면적. 트림 바깥은 구조물이 그리는 폴리라인을 따르고, 그 선이 + * 없으면 트림 경계 표고에서 끊어 **지반선에 붙인다**(그 바깥은 손대지 않은 원지반이라 + * 면적이 0이 된다). + */ +export function computeStructureAreas(input: StructureAreaInput): StructureAreaResult | null { + const profile = drawnProfile(input); + if (!profile) return null; + const { drawnZ, ground } = profile; + const { trim } = input; // 적분 격자 = 지반 샘플 ∪ 설계선 꼭짓점 ∪ 트림 경계 ∪ 구조물 폴리라인 꼭짓점. // 꺾이는 자리를 모두 넣어야 사다리꼴 적분이 모서리를 잘라먹지 않는다. @@ -154,3 +165,39 @@ export function computeStructureAreas(input: StructureAreaInput): StructureAreaR cutRockAreaM2: cutRock, }; } + +/** + * 벽 몸 도형(볼록 다각형) 중 **성토 폐회로 안에 든 넓이**(㎡). + * 폐회로는 구조물 면적을 안 뺌(2026-09-06 확정)이라 벽 몸이 성토에도 셈 — 고치지 않고 그 겹침을 + * 재서 수량 사유로 올림(2026-09-14 브레인). 세로줄로 잘라 [max(몸 밑, 지반), min(몸 위, 설계선)] 을 쌓음. + */ +export function wallBodyInFillM2( + points: Array<{ offset: number; elevation: number }>, + input: StructureAreaInput, + steps = 400, +): number { + const profile = drawnProfile(input); + if (!profile || points.length < 3) return 0; + const offsets = points.map((point) => point.offset); + const lo = Math.min(...offsets); + const hi = Math.max(...offsets); + if (!(hi > lo)) return 0; + const dx = (hi - lo) / steps; + let area = 0; + for (let index = 0; index < steps; index += 1) { + const x = lo + (index + 0.5) * dx; + const hits: number[] = []; + points.forEach((p, k) => { + const q = points[(k + 1) % points.length]; + if ((p.offset - x) * (q.offset - x) > 0 || Math.abs(q.offset - p.offset) < 1e-12) return; + hits.push( + p.elevation + ((q.elevation - p.elevation) * (x - p.offset)) / (q.offset - p.offset), + ); + }); + if (hits.length < 2) continue; + const top = Math.min(Math.max(...hits), profile.drawnZ(x)); + const bottom = Math.max(Math.min(...hits), profile.ground(x)); + if (top > bottom) area += (top - bottom) * dx; + } + return area; +} diff --git a/common_util/common_util_culvert_sets.ts b/common_util/common_util_culvert_sets.ts index 83a726e9..92be3434 100644 --- a/common_util/common_util_culvert_sets.ts +++ b/common_util/common_util_culvert_sets.ts @@ -46,6 +46,18 @@ export const FORD_WALL_THICKNESS_M = 0.2; export const FORD_DEFAULT_WIDTH_M = 10.0; export const FORD_PAVEMENT_DEFAULT_WIDTH_M = 5.0; export const BOX_COVER_M = 0.5; +/** 관 벽 최소 높이 — 횡단도 `B06_Section_UI_Cross_Culvert_Const.ts` 와 같은 값(거울 시험이 셋을 대조). + * 높이를 안 적은 관 벽은 이 값으로 섬(2026-09-14 브레인 판정 · 옛 등록부 기본 2.5 걷음). */ +export const REVET_FREEBOARD_M = 0.5; +export const REVET_HEIGHT_STEP_M = 0.1; +export const REVET_EMBED_DEPTH_M = 0.5; + +/** 관 벽 순수 높이(근입 포함) 최소값 — 파이썬 `pipe_wall_min_height_m`. */ +export function pipeWallMinHeightM(diameterM: number): number { + const raw = diameterM + REVET_FREEBOARD_M; + const steps = Math.ceil(raw / REVET_HEIGHT_STEP_M - 1e-9); + return round(steps * REVET_HEIGHT_STEP_M + REVET_EMBED_DEPTH_M, 3); +} /** 시설 종류 — 파이썬 `common_util_drainage_pipes` 의 상수와 같은 문자열. */ const FACILITY_PIPE = "pipe"; @@ -82,6 +94,7 @@ function sideSpec( options: Record, defaults: Record, side: string, + diameterM: number, ): CulvertSetSpec { const structure = text(options[`${side}_type`]) ?? text(defaults[`${side}_type`]) ?? "기슭막이"; const spec: CulvertSetSpec = { role: side, structure }; @@ -100,7 +113,7 @@ function sideSpec( const height = num( options[`${side}_revet_height_m`], - num(defaults[`${side}_revet_height_m`], null), + num(defaults[`${side}_revet_height_m`], pipeWallMinHeightM(diameterM)), ); const length = num( options[`${side}_revet_length_m`], @@ -133,17 +146,18 @@ function culvertSet( ): CulvertSetSpec { const defaults = registry.pipe ?? {}; const diameterMm = num(options.pipe_diameter_mm, num(defaults.pipe_diameter_mm, 1000.0)); + const diameterM = pipeDiameterM(diameterMm); return { type: "pipe", pipe_kind: text(options.pipe_kind) ?? text(defaults.pipe_kind), - diameter_m: pipeDiameterM(diameterMm), + diameter_m: diameterM, min_cover_m: MIN_PIPE_COVER_M, // 관 기슭막이 기초 축 — **유입·유출 한 칸으로** 받는다. 한 공사에서 한쪽만 기초유로 // 하는 일은 드물어 폼을 둘로 늘리지 않았다(2026-09-09). 실무가 갈라 쓰는 것이 // 확인되면 그때 나눌 것. foundation: text(options.revet_foundation) ?? text(defaults.revet_foundation), - inlet: sideSpec(options, defaults, "inlet"), - outlet: sideSpec(options, defaults, "outlet"), + inlet: sideSpec(options, defaults, "inlet", diameterM), + outlet: sideSpec(options, defaults, "outlet", diameterM), }; } @@ -163,6 +177,10 @@ function revetSide(values: Record, role: string): CulvertSetSpe revet_before_m: num(values[`${role}_revet_before_m`], num(values.before_m, null)), revet_after_m: num(values[`${role}_revet_after_m`], num(values.after_m, null)), face_slope: REVET_FACE_SLOPE, + // 전면 기울기를 B08 과 같은 칸으로 가르는 값 — 저장 원본 그대로(파이썬 `_revet_side`). + face_side: values.side ?? null, + face_role: values.face_role ?? null, + face_slope_ratio: values.face_slope_ratio ?? null, }; if (height !== null && height > 0) { spec.apron_length_m = round(height * APRON_LENGTH_FACTOR, 3); @@ -233,6 +251,37 @@ function wingSpec( }; } +/** 정본에 안 적혀 기본값으로 그린 칸 「이름 값」 — 파이썬 `_defaulted`(2026-09-14 브레인 판정 ②). */ +function defaulted( + values: Record, + fields: ReadonlyArray<[string, string, unknown]>, +): string[] { + return fields + .filter(([key, , used]) => used !== null && used !== undefined && text(values[key]) === null) + .map(([, label, used]) => `${label} ${String(used)}`); +} + +/** 날개벽 한쪽의 기본값 칸 — 파이썬 `_wing_defaulted`. */ +function wingDefaulted( + values: Record, + wing: CulvertSetSpec, + side: string, +): string[] { + const name = side === "in" ? "유입" : "유출"; + const prefix = `wing_${side}`; + const fields: Array<[string, string, unknown]> = [ + [prefix, `${name} 날개벽`, wing.installed ? "있음" : "없음"], + ]; + if (wing.installed) { + fields.push( + [`${prefix}_height_m`, `${name} 날개벽 높이`, wing.height_m], + [`${prefix}_length_m`, `${name} 날개벽 길이`, wing.length_m], + [`${prefix}_angle_deg`, `${name} 날개벽 각도`, wing.angle_deg], + ); + } + return defaulted(values, fields); +} + /** 세월교 1개소의 세트 제원(관 + 양측 측벽 + 바닥판 + 날개벽 연장). */ function fordSet( values: Record, @@ -243,19 +292,35 @@ function fordSet( const width = num(values.ford_width_m, num(defaults.ford_width_m, null)); const count = num(values.pipe_count, num(defaults.pipe_count, null)); const depth = num(values.ford_height_m, null); + const kind = text(values.pipe_kind) ?? text(defaults.pipe_kind); + const span = width && width > 0 ? width : FORD_DEFAULT_WIDTH_M; + const pipeCount = count ? Math.max(Math.trunc(count), 1) : 1; + const wingIn = wingSpec(values, defaults, "in"); + const wingOut = wingSpec(values, defaults, "out"); return { type: "ford", - pipe_kind: text(values.pipe_kind) ?? text(defaults.pipe_kind), + pipe_kind: kind, diameter_m: pipeDiameterM(diameterMm), - pipe_count: count ? Math.max(Math.trunc(count), 1) : 1, - span_m: width && width > 0 ? width : FORD_DEFAULT_WIDTH_M, + pipe_count: pipeCount, + span_m: span, // 월류 높이 — 구체 위 노면은 이만큼 낮게 앉는다. 없으면 0 = 내리지 않는다. overflow_depth_m: depth && depth > 0 ? depth : 0.0, slab_thickness_m: FORD_SLAB_THICKNESS_M, wall_thickness_m: FORD_WALL_THICKNESS_M, min_cover_m: MIN_PIPE_COVER_M, - wing_in: wingSpec(values, defaults, "in"), - wing_out: wingSpec(values, defaults, "out"), + wing_in: wingIn, + wing_out: wingOut, + defaulted: [ + ...defaulted(values, [ + ["pipe_kind", "관종", kind], + ["pipe_diameter_mm", "관경", diameterMm], + ["ford_width_m", "월류 폭", span], + ["pipe_count", "배관 수량", pipeCount], + ["ford_height_m", "월류 높이", 0], + ]), + ...wingDefaulted(values, wingIn, "in"), + ...wingDefaulted(values, wingOut, "out"), + ], }; } @@ -267,12 +332,14 @@ function fordPavementSet( const defaults = registry.ford_pavement ?? {}; const width = num(values.ford_width_m, num(defaults.ford_width_m, null)); const depth = num(values.ford_height_m, null); + const span = width && width > 0 ? width : FORD_PAVEMENT_DEFAULT_WIDTH_M; return { type: "ford_pavement", - span_m: width && width > 0 ? width : FORD_PAVEMENT_DEFAULT_WIDTH_M, + span_m: span, // 노선 중심에서 잰 깊이. 없으면 화면이 파임을 그리지 않는다(수치를 지어내지 않는다). depth_m: depth && depth > 0 ? depth : null, slope_pct: num(values.ford_slope_pct, null), + defaulted: defaulted(values, [["ford_width_m", "월류 폭", span]]), }; } @@ -286,6 +353,8 @@ function boxSet( const innerHeight = num(values.body_height_m, num(defaults.body_height_m, 2.0)); const wall = FORD_WALL_THICKNESS_M; const slab = FORD_SLAB_THICKNESS_M; + const wingIn = wingSpec(values, defaults, "in"); + const wingOut = wingSpec(values, defaults, "out"); return { type: "box", inner_width_m: innerWidth || 2.0, @@ -295,8 +364,16 @@ function boxSet( top_thickness_m: slab, cover_m: BOX_COVER_M, span_m: boxSpanM(innerWidth || 2.0), - wing_in: wingSpec(values, defaults, "in"), - wing_out: wingSpec(values, defaults, "out"), + wing_in: wingIn, + wing_out: wingOut, + defaulted: [ + ...defaulted(values, [ + ["body_width_m", "본체 폭", innerWidth || 2.0], + ["body_height_m", "본체 높이", innerHeight || 2.0], + ]), + ...wingDefaulted(values, wingIn, "in"), + ...wingDefaulted(values, wingOut, "out"), + ], }; } diff --git a/common_util/common_util_masonry_slope.ts b/common_util/common_util_masonry_slope.ts new file mode 100644 index 00000000..7b9f1a98 --- /dev/null +++ b/common_util/common_util_masonry_slope.ts @@ -0,0 +1,88 @@ +/* ============================================================================= + * common_util_masonry_slope.ts + * 벽 전면 기울기(1:n 의 n) — 품셈 13-4-4 [주]⑪ 표준경사 표(원문 L7185~7191)를 읽음. + * + * ⚠ **파이썬과 짝**(CLAUDE.md 5장 ①) — `B08_Quantity_Engine_UnitQuantity_StoneSpec.face_slope_ratio` + * + `common_util_structure_face_role.structure_face_role_of`. 거울 시험 + * `resources/tester/test_b06_wall_lean_table.py` 가 네 갈래(직고 × 성토/절토 × 메/찰 + 사용자 칸)를 + * 한 칸씩 대조. 한쪽만 고치면 횡단도 벽과 B08 수량이 갈림(2026-09-14 브레인 판정 「양쪽 다 이 표를」). + * ⚠ 표는 부르는 쪽이 넘김 — 이 파일은 아무것도 import 하지 않음(거울 시험이 단독으로 돌림). + * ========================================================================== */ + +/** 표준경사 표 — `resources/data_masonry/masonry_slope_*.json` 의 모양. */ +export interface MasonrySlopeTable { + steps_m?: number[]; + table?: Record>; +} + +/** 벽 한 매의 기울기 입력 — B08 이 그 벽을 셀 때 보는 칸과 같음. */ +export interface WallLeanInput { + /** 형태 — 「돌쌓기(찰)」·「돌쌓기(메)」만 표를 읽음(나머지는 종전 0.3). */ + form?: string | null; + /** 직고(m) — 순수 높이. */ + height_m: number; + /** 측점 단면유형 `design.section_mode`. */ + section_mode?: string | null; + /** 설치 측 — 「자동(성토 쪽)」·「좌」·「우」(없으면 자동). */ + side?: string | null; + /** 성토/절토 칸 — 고르면 이김. */ + face_role?: string | null; + /** 전면 기울기 칸 — 수(>0)면 이김(파이썬 `_num` 과 같이 글은 안 봄). */ + face_slope_ratio?: number | string | null; +} + +/** 표를 못 고를 때의 종전값 — 파이썬 `LEGACY_FACE_SLOPE_RATIO`. */ +export const LEGACY_LEAN_RATIO = 0.3; + +const FILL = "성토"; +const CUT = "절토"; +/** 형태 → 찰(true)/메(false) — 파이썬 `REVETMENT_STONE_FORMS`. */ +const STONE_FORMS: Record = { "돌쌓기(찰)": true, "돌쌓기(메)": false }; +/** 단면유형 → (좌측 역할, 우측 역할). 좌 = +offset. */ +const MODE_ROLES: Record = { + left_cut: [CUT, FILL], + right_cut: [FILL, CUT], + both_cut: [CUT, CUT], + both_fill: [FILL, FILL], +}; +const LEFT = new Set(["좌", "left", "L"]); +const RIGHT = new Set(["우", "right", "R"]); +const AUTO = new Set(["자동(성토 쪽)", "자동", "auto", ""]); + +/** 성토/절토 — 칸이 이기고, 비면 단면유형 + 설치 측. 못 가르면 `null`(성토로 눅이지 않음). */ +export function faceRoleOf( + sectionMode: string | null | undefined, + side: string | null | undefined, + faceRole?: string | null, +): string | null { + const picked = String(faceRole ?? "").trim(); + if (picked === FILL || picked === CUT) return picked; + const roles = MODE_ROLES[String(sectionMode ?? "").trim()]; + if (!roles) return null; + const where = String(side ?? "").trim(); + if (LEFT.has(where)) return roles[0]; + if (RIGHT.has(where)) return roles[1]; + if (AUTO.has(where)) return roles[0] === CUT && roles[1] === CUT ? null : FILL; + return null; +} + +/** 벽 전면 기울기 n — 표를 못 고르면 종전 0.3. */ +export function wallLeanRatio(table: MasonrySlopeTable, input: WallLeanInput): number { + const wet = STONE_FORMS[String(input.form ?? "").trim()]; + if (wet === undefined) return LEGACY_LEAN_RATIO; + const given = input.face_slope_ratio; + if (typeof given === "number" && Number.isFinite(given) && given > 0) return given; + const face = faceRoleOf(input.section_mode, input.side, input.face_role); + if (!face) return LEGACY_LEAN_RATIO; + const row = table.table?.[wet ? "찰쌓기" : "메쌓기"]?.[face]; + if (!row?.length) return LEGACY_LEAN_RATIO; + const steps = table.steps_m ?? []; + let index = steps.length; + for (let i = 0; i < steps.length; i += 1) { + if (input.height_m <= steps[i]) { + index = i; + break; + } + } + return row[Math.min(index, row.length - 1)]; +} diff --git a/common_util/common_util_node_bundle.py b/common_util/common_util_node_bundle.py index 3b38affe..9584ab0d 100644 --- a/common_util/common_util_node_bundle.py +++ b/common_util/common_util_node_bundle.py @@ -21,6 +21,8 @@ logger = logging.getLogger(__name__) ROOT = Path(__file__).resolve().parents[1] # 번들이 낡았는지 재는 대상 — 기하 계통이 걸쳐 있는 폴더 + 구조물도 식 풀이(B08, 2026-09-13). SOURCE_DIRS = ("B05_Profile", "B06_Section", "B08_Quantity", "common_util") +# 번들에 박히는 자료(JSON) — 벽 전면 기울기 표준경사 표(2026-09-14 B5). 표만 바뀌어도 다시 빌드. +SOURCE_DATA_DIRS = ("resources/data_masonry",) # 번들 만들기·실행 상한(초). 실측 번들 실행 0.1초, 빌드 3초 수준이라 넉넉하다. BUILD_TIMEOUT_S = 300 RUN_TIMEOUT_S = 600 @@ -48,6 +50,10 @@ def bundle_stale(bundle: Path) -> bool: for path in (ROOT / directory).rglob("*.ts"): if path.stat().st_mtime > built_at: return True + for directory in SOURCE_DATA_DIRS: + for path in (ROOT / directory).glob("*.json"): + if path.stat().st_mtime > built_at: + return True return False diff --git a/common_util/common_util_project_settings.py b/common_util/common_util_project_settings.py index 0b312aee..80e8ea6d 100644 --- a/common_util/common_util_project_settings.py +++ b/common_util/common_util_project_settings.py @@ -160,6 +160,16 @@ def default_settings() -> dict[str, Any]: # 노체다짐(9-16-2) — **기본 꺼짐**(2026-09-13 판정). [주]⑤ 「대규모 성토지로서 # 층다짐이 필요한 경우 적용」 조건부라 늘 서지 않는다. 켜면 토공집계에 별도 줄. "subgrade_compaction_enabled": False, + # 임목폐기물 — 현장 조사값 넷 `{stems_per_1000m2, dbh_cm, height_m, unit_weight_kg_m3}`. + # ⚠ 기본값을 두지 않는다 — 비면 톤이 안 선다(수종·임분마다 달라 지어내지 않음). + "tree_waste": {}, + # 뿌리 산정법 — `None` 은 분배비 15/85(기본) · `"root_ball"` 은 뿌리분 체적 × 1,300㎏/㎥ + # (환경친화적인 도로건설 지침 부록1). 뿌리분 직경은 `tree_waste.root_ball_diameter_m`. + "tree_waste_root_method": None, + # 처리단가(원/톤) — KCRA 중간처리단가 미확보라 **수동**. 넣으면 금액이 서되 미확정. + "tree_waste_unit_price_krw_per_ton": None, + # 폐기물 분리발주 — 기본 아님(경비로 원가에 듦 · 2026-09-14 판정). + "waste_separate_order": False, "dataset_versions": {}, }, "estimation": { diff --git a/common_util/common_util_structure_face_role.py b/common_util/common_util_structure_face_role.py index 85422a1e..af614785 100644 --- a/common_util/common_util_structure_face_role.py +++ b/common_util/common_util_structure_face_role.py @@ -59,3 +59,18 @@ def structure_face_role(section_mode: str | None, side: str | None) -> tuple[str return None, f"{mode} · 자동(성토 쪽)인데 성토면이 없음 — 가를 근거 없음" return FILL, f"{mode} · 자동(성토 쪽) → 성토면" return None, f"설치 측을 알 수 없음({side!r}) — 성절토를 가를 근거 없음" + + +#: 성토/절토를 **사용자가 고르는 칸** — 비우면 위 판정(계산이지 기본값이 아님 · 2026-09-14 브레인 판정 ㉲). +FACE_ROLE_KEY = "face_role" + + +def structure_face_role_of( + section_mode: str | None, options: dict | None +) -> tuple[str | None, str]: + """칸 `face_role`(성토·절토)을 골랐으면 그 값, 비었으면 단면유형 + 설치 측 판정.""" + values = options or {} + picked = str(values.get(FACE_ROLE_KEY) or "").strip() + if picked in (FILL, CUT): + return picked, f"사용자 지정 {picked}면(성토/절토 칸)" + return structure_face_role(section_mode, values.get("side")) diff --git a/common_util/common_util_structure_walls.ts b/common_util/common_util_structure_walls.ts index d6bb4f40..435e33c4 100644 --- a/common_util/common_util_structure_walls.ts +++ b/common_util/common_util_structure_walls.ts @@ -37,6 +37,9 @@ export interface WallSpec { tiers: number | null; lift_m: number | null; shift_m: number | null; + /** 전면 기울기 판정 칸 — 성토/절토 · 전면 기울기(저장 원본 · B08 과 같은 칸, 2026-09-14 B5). */ + face_role: string | null; + face_slope_ratio: number | string | null; } /** 구간 경계 측점을 구간 안으로 볼 허용 오차(m) — 파이썬 `_EDGE_TOLERANCE_M`. */ @@ -95,6 +98,8 @@ export function wallSpecsFrom( tiers: num(options.tiers), lift_m: num(options.lift_m), shift_m: num(options.shift_m), + face_role: (options.face_role as string) ?? null, + face_slope_ratio: (options.face_slope_ratio as number | string) ?? null, }); } return specs; diff --git a/resources/data_aliases/aliases_2026-01-01.json b/resources/data_aliases/aliases_2026-01-01.json index 22ba4693..035083df 100644 --- a/resources/data_aliases/aliases_2026-01-01.json +++ b/resources/data_aliases/aliases_2026-01-01.json @@ -11,6 +11,38 @@ "pum_edition": "2026-01-01", "basis": "노임 표준 직종명은 「화약취급공」(노임 카탈로그 1016) — 품셈 9-5 표가 줄여 적음. 2026-09-13 사용자 위임 판정(브레인)" }, + { + "axis": "resource", + "from": "소형브레이커", + "to": "5210-0010", + "scope": "FP-09-19-03", + "pum_edition": "2026-01-01", + "basis": "9-19-3 비탈면 고르기(발파암) 표가 규격을 안 적음 — 그 값 「(2.45+3.05)/2/10㎡」 은 9-19-1 토사면 고르기 표(연암 2.45·보통암+경암 3.05)에서 왔고 9-19-1 [주]① 「소형브레이커는 1㎥/분」 → 소형브레이커(공압식) 1.0㎥/min(9-19-3 ← 9-19-1 표 ← [주]①, 값의 출처가 규격을 정함). 2026-09-14 브레인 승인" + }, + { + "axis": "resource", + "from": "공기압축기", + "to": "5205-0035", + "scope": "FP-09-19-01", + "pum_edition": "2026-01-01", + "basis": "9-19-1 토사면 고르기 절토면 표(원문 L5414)가 자원 머리에 규격을 안 적음 — [주]① 「공기압축기는 3.5㎥/분, 소형브레이커는 1㎥/분, 굴착기는 0.7㎥를 기준한 것이다」 → 공기압축기(이동식) 3.5. 칸이 규격을 적은 줄(성토면 굴착기 0.6㎥)은 안 덮음. 2026-09-14 브레인 승인(⑤ Ⓐ)" + }, + { + "axis": "resource", + "from": "소형브레이커", + "to": "5210-0010", + "scope": "FP-09-19-01", + "pum_edition": "2026-01-01", + "basis": "9-19-1 토사면 고르기 절토면 표(원문 L5414)가 자원 머리에 규격을 안 적음 — [주]① 「공기압축기는 3.5㎥/분, 소형브레이커는 1㎥/분, 굴착기는 0.7㎥를 기준한 것이다」 → 소형브레이커(공압식) 1.0㎥/min. 칸이 규격을 적은 줄(성토면 굴착기 0.6㎥)은 안 덮음. 2026-09-14 브레인 승인(⑤ Ⓐ)" + }, + { + "axis": "resource", + "from": "굴착기", + "to": "0201-0070", + "scope": "FP-09-19-01", + "pum_edition": "2026-01-01", + "basis": "9-19-1 토사면 고르기 절토면 표(원문 L5414)가 자원 머리에 규격을 안 적음 — [주]① 「공기압축기는 3.5㎥/분, 소형브레이커는 1㎥/분, 굴착기는 0.7㎥를 기준한 것이다」 → 굴착기(무한궤도) 0.7(카탈로그 0.7 은 무한궤도뿐). 칸이 규격을 적은 줄(성토면 굴착기 0.6㎥)은 안 덮음. 2026-09-14 브레인 승인(⑤ Ⓐ)" + }, { "axis": "variant", "from": "리핑암", diff --git a/resources/data_masonry/masonry_back_length_2026-01-01.json b/resources/data_masonry/masonry_back_length_2026-01-01.json new file mode 100644 index 00000000..c8de0f01 --- /dev/null +++ b/resources/data_masonry/masonry_back_length_2026-01-01.json @@ -0,0 +1,40 @@ +{ + "schema_version": "1.0", + "dataset_id": "masonry_back_length_standard", + "effective_date": "2026-01-01", + "note": "돌쌓기 뒷길이 표준 — **높이 × 메/찰** 로 범위가 갈린다. 뒷길이를 안 고른 돌쌓기는 붙박이 45㎝ 로 서고 있었는데, 그 자리를 이 표의 **하한**으로 바꿈(2026-09-14 브레인 판정). 사용자가 칸에서 고르면 그 값이 이김.", + "source": { + "doc": "산림사업 표준품셈(산림청고시 제2025-82호) 13-4-4 [주]⑩ <뒷길이 표준>", + "line": "원문 L7172~7181", + "quote": "높은 메쌓기는 밑으로 내려갈수록 뒷길이를 길게 하는 것이 원칙이다 · 높이는 첨단으로부터 기준 한다 · 특수한 조건에서는 본 기준과 달리 적용할 수 있다" + }, + "policy": { + "suggestion": "⭐ 제안값 = **범위 안에 드는 품셈 규격 중 가장 작은 것**(브레인 판정 ㉮). 하한이 규격(25·30·35·45·55·60·75)에 없으면 그 위 첫 규격 — 메쌓기 ~3m·~5m 하한 36㎝ 는 45㎝. 35 로 내리면 품셈 범위 아래로 나가 안 됨.", + "boundary": "⚠ 원문 표기 `∼1.5 · ∼3 · ∼5 · ∼7 · 7이상` — 표준경사 표(`masonry_slope`)와 같이 「∼」를 **이하**로 읽어 경계값은 앞 칸(높이 3.0m → `~3` 칸).", + "no_write_back": "⚠ 제안값을 저장에 박지 않음 — 「안 고름」과 「45 로 고름」이 구별돼야 함(표준경사와 같은 자리)." + }, + "steps_m": [1.5, 3.0, 5.0, 7.0], + "table_cm": { + "메쌓기": [ + [25, 35], + [36, 45], + [36, 60], + [45, 75], + [75, null] + ], + "찰쌓기": [ + [25, 35], + [30, 35], + [35, 45], + [35, 55], + [45, 60] + ] + }, + "not_here": { + "note": "이 표가 쓰지 않는 칸.", + "items": [ + "돌붙이기 열(메 25~60 · 찰 20~40)은 높이로 안 갈림 — 바닥막이 돌붙임은 뒷길이가 붙박이(`_UnitQuantity_Revetment.BED_SILL_FORMS`)라 여기서 안 씀", + "골막이는 뒷길이 기본이 종전대로 45㎝ — 이 표의 대상(돌쌓기 13-4)이 아님" + ] + } +} diff --git a/resources/data_masonry/revetment_sabang_2026-01-01.json b/resources/data_masonry/revetment_sabang_2026-01-01.json new file mode 100644 index 00000000..b992c956 --- /dev/null +++ b/resources/data_masonry/revetment_sabang_2026-01-01.json @@ -0,0 +1,44 @@ +{ + "schema_version": "1.0", + "dataset_id": "revetment_sabang", + "effective_date": "2026-01-01", + "note": "독립 기슭막이 치수 **한 벌** — 높이 · 계획비탈 · 둑마루 두께 · 뒷채움 조약돌 두께를 한 자리에 둠(2026-09-14 브레인 판정). B05 칸 밑 근거와 B08 사유가 이 파일 하나를 같이 읽음.", + "scope": "⚠ 사방(계류) 교본 기준 — 임도 배수관 유입·유출부 적용은 확인 남음", + "rank": "품셈(산림청고시 제2025-82호)은 행정규칙 · 사방기술교본은 참고자료 — 제안값은 품셈 표, 교본은 근거로 병기(브레인 판정 ㉯ · 운영지침 「프로그램 기본값 = 현행 법령·행정규칙」)", + "items": { + "height": { + "label": "높이", + "rule": "계획홍수위 + 0.5~0.7m", + "freeboard_m": [0.5, 0.7], + "input": "설계자 입력 · 비우면 미확정(금액에 안 들어감)", + "source": "사방기술교본 2-나:141 · 3-가:181", + "quote": "기슭막이 높이는 계획홍수위 이상으로 하여 홍수시에도 넘치지 않도록 시공한다, 일반적으로 계획홍수위 보다 0.5~0.7m 높게 하며, 계류의 만곡부에서는 凹안이 凸안에 비해 수위가 높아지므로 더 높게 시공한다" + }, + "face_slope": { + "label": "계획비탈", + "standard_ratio": [0.3, 0.5], + "extended_ratio": [1.0, 1.5], + "source": "사방기술교본 2-나:129 · 3-가:151", + "quote": "기슭막이의 계획비탈은 1:0.3∼0.5를 표준으로 하고, 사용 재료와 현장 연건에 따라 1:1.0∼1.5 정도로 계획할 수 있다", + "applied": "제안값은 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191) — 이 범위는 근거로만 보임 · 표 값이 범위 밖이면 사유만(교본을 이기려 들지 않음)" + }, + "crown_thickness": { + "label": "둑마루 두께", + "standard_m": [0.3, 0.5], + "source": "사방기술교본 2-나:143 · 3-가:201 · 3-가:219(콘크리트기슭막이) · 2-나:135(콘크리트 0.3m)", + "quote": "기슭막이 둑마루 두께는 0.3∼0.5m를 표준으로 하고, 둑마루 기울기는 원칙적으로 계획계상기울기와 일치하도록 시공한다", + "applied": "⚠ 콘크리트 기슭막이 기준 — 돌쌓기 상부 두께는 식(뒷길이 + 0.30)으로 따로 셈 · 식을 안 바꾸고 근거로만(브레인 판정 ㉱)" + }, + "backfill_pebble": { + "label": "뒷채움 조약돌 두께", + "source": "사방기술교본 2-나:133(「사방품셈 규정에 의하여」) → 품셈 13-4-4 [주]⑨ 원문 L7164~7171", + "steps_m": [1.5, 3.0, 5.0, 7.0], + "fill_cm": { + "top": [20, 40], + "bottom": [[30, 60], [45, 75], [60, 100], [80, 140]] + }, + "cut_cm": [30, 40], + "applied": "막자갈은 정본 뒷채움 폭(상 0.30 · 하 0.45)으로 셈 — 이 표는 근거로만 보임(`STONE_BACKFILL_WIDTH_M` 주석의 갈림)" + } + } +} diff --git a/resources/data_material_surcharge/_manifest.json b/resources/data_material_surcharge/_manifest.json index ce84dbd3..31b2bde7 100644 --- a/resources/data_material_surcharge/_manifest.json +++ b/resources/data_material_surcharge/_manifest.json @@ -6,8 +6,8 @@ "files": [ { "file": "material_surcharge_2026-01-01.json", - "sha256": "75138c44944e7f56df850ea5749b60fc35a2db0c8b282b9a5d61927d69ac4fcc", - "size_bytes": 4724 + "sha256": "30a1e11acb6f6bd52c72e0ff471c96f772917858306ea61f66ecbcb5df433c9f", + "size_bytes": 5087 } ] } diff --git a/resources/data_material_surcharge/material_surcharge_2026-01-01.json b/resources/data_material_surcharge/material_surcharge_2026-01-01.json index 3451db13..2e732b1e 100644 --- a/resources/data_material_surcharge/material_surcharge_2026-01-01.json +++ b/resources/data_material_surcharge/material_surcharge_2026-01-01.json @@ -149,18 +149,20 @@ { "material": "막자갈", "rate": 4, - "pumsem": "const", + "pumsem": "forest", + "listed_in": "산림청고시 제2025-82호 1-3-1 3호 「부순돌ㆍ자갈ㆍ막자갈 4」(건설품셈도 같은 조건)", "listed_condition": "노상 및 노반재료(선택층·보조기층·기층)", "our_usage": "돌쌓기 뒤채움", - "why_not_applied": "조건이 노반재료 한정이라 뒤채움에 그대로 쓸 근거가 없음" + "why_not_applied": "조문이 쓰임을 노반재료로 못박아 뒤채움에 그 상한을 끌어올 근거가 없음(2026-09-14 브레인 판정 보류 유지 · 사방기술교본도 할증률 없음)" } ] }, "not_found": { "note": "산림·건설 두 품셈의 재료 할증률표를 다 뒤졌으나 **이름이 없는** 자재. 석재 계열은 건설품셈도 해상 사석(기초·피복·뒤채움)과 원석(마름돌용)만 다룬다.", - "materials": ["야면석", "고임돌", "물구멍관"], + "materials": ["야면석", "고임돌", "물구멍관", "못"], "checked": [ "산림품셈 1-3-1 전 19종", + "못 — 산림청고시 제2025-82호 1-3-1 6호 기타재료에 없음(2026-09-14 브레인 확인)", "건설품셈 1-3-1 1~7호(콘크리트·노반·관기초·토사(해상)·사석(해상)·속채움(해상)·강재류)", "건설품셈 제7장 돌공사 — 재료 할증률표 없음" ] diff --git a/resources/data_resource_catalog/resource_catalog_ext_2026-01-01.json b/resources/data_resource_catalog/resource_catalog_ext_2026-01-01.json index cd7c679b..0b71fbef 100644 --- a/resources/data_resource_catalog/resource_catalog_ext_2026-01-01.json +++ b/resources/data_resource_catalog/resource_catalog_ext_2026-01-01.json @@ -259,6 +259,89 @@ "F0243" ] } + }, + { + "code": "AR-M-423cfb79", + "kind": "material", + "name": "고무링", + "spec": "∅800mm", + "unit": "개", + "source": { + "pum_edition": "2026-01-01", + "pum_table_ids": [ + "F0346" + ] + } + }, + { + "code": "AR-M-18c0ce85", + "kind": "material", + "name": "고무링", + "spec": "∅1000mm", + "unit": "개", + "source": { + "pum_edition": "2026-01-01", + "pum_table_ids": [ + "F0346" + ] + } + }, + { + "code": "AR-M-d9d5aa16", + "kind": "material", + "name": "고무링", + "spec": "∅1200mm", + "unit": "개", + "source": { + "pum_edition": "2026-01-01", + "pum_table_ids": [ + "F0346" + ] + } + }, + { + "code": "AR-M-0cc44237", + "kind": "material", + "name": "지수활제", + "spec": "", + "unit": "g", + "source": { + "pum_edition": "2026-01-01", + "pum_table_ids": [ + "F0346" + ] + } + }, + { + "code": "AR-M-a2926452", + "kind": "material", + "name": "치즐", + "spec": "대형브레이커용", + "unit": "본", + "source": { + "pum_edition": "2026-01-01", + "pum_table_ids": [ + "F0241", + "F0244", + "F0259", + "F0260", + "F0267", + "F0268", + "F0269", + "F0270", + "F0271", + "F0272", + "F0273", + "F0274", + "F0275", + "F0276", + "F0277", + "F0278" + ], + "stmate_codes": [ + "M00118" + ] + } } ] } diff --git a/resources/data_structure_unit/structure_unit_observed_2026-01-01.json b/resources/data_structure_unit/structure_unit_observed_2026-01-01.json index 99660f72..85f1bfda 100644 --- a/resources/data_structure_unit/structure_unit_observed_2026-01-01.json +++ b/resources/data_structure_unit/structure_unit_observed_2026-01-01.json @@ -33,26 +33,43 @@ "unit": "m", "source": "uljin_library", "source_note": "§7 옹벽류 — 반중력식옹벽 H=2.0", + "section": { + "note": "단면 치수(m) — 아래 수량이 나온 그 단면. 출처 소광리 07-구조도 「옹벽2.0」 탭 산출식(T22 기초 0.4×1.6+0.3×0.35 · T23 벽체 (0.3+0.45)÷2×1.6 · T25 버림 0.1×(1.8−0.35) · T26·T27 유로폼 √(1.6²+0.032²)+√(1.6²+0.118²) · T31 파이프 1.6÷2×0.4)과 같은 탭 일반도(앞굽 950·뒷굽 200·키 자리 뒷굽 끝에서 500)", + "footing": { "width_m": 1.6, "thickness_m": 0.4, "toe_m": 0.95 }, + "wall": { "height_m": 1.6, "top_m": 0.3, "front_batter_m": 0.118, "back_batter_m": 0.032 }, + "key": { "width_m": 0.35, "depth_m": 0.3, "from_heel_m": 0.5 }, + "blinding": { "thickness_m": 0.1, "overhang_m": 0.1 }, + "weep": { + "pipe_length_m": 0.4, + "note": "소광리 「옹벽2.0」 T31 「파이프 φ50 (1.6 ÷ 2) × 0.4」 — 벽 높이 ÷ 개소당 벽면적 × 관 길이. 관 길이 0.4 는 **실무 관측값**(품셈에 없음) · 개소당 벽면적·지름은 제원 칸(비우면 2㎡ · Ø50)" + }, + "trench": { + "clearance_m": 0.3, + "side_slope": 0.5, + "note": "소광리 「옹벽2.0」 탭에 터파기 줄이 없음 → 같은 파일 「식생옹벽블럭(H=2.0)」 F31·H31 기초 터파기 — 밑폭 = 기초 폭 + 0.6(양쪽 0.3) · 윗폭 = 밑폭 + 0.5 × 판 깊이 × 2(옆면 1:0.5). 브레인 판정(2026-09-14, 수직 → 1:0.5 로 고침) · 벽 높이 몫은 지반선이 제원에 없어 제외" + } + }, "components": [ { "name": "콘크리트", "unit": "㎥", - "amount": 1.35, + "amount": 1.345, "destination": "unit_price", - "basis_note": "기초 0.75 + 벽체 0.60" + "basis_note": "기초 0.745 + 벽체 0.60 · 원문 값(라이브러리 표기 1.35)" }, { "name": "버림콘크리트", "unit": "㎥", - "amount": 0.15, - "destination": "unit_price" + "amount": 0.145, + "destination": "unit_price", + "basis_note": "원문 값(라이브러리 표기 0.15)" }, { "name": "유로폼", "unit": "㎡", - "amount": 3.2, + "amount": 3.205, "destination": "unit_price", - "basis_note": "배면+전면" + "basis_note": "배면+전면 · 원문 값(라이브러리 표기 3.20)" }, { "name": "합판거푸집", @@ -69,13 +86,15 @@ "basis_note": "Ø50" }, { - "name": "이형철근 D13", + "name": "이형철근", + "spec": "D13", "unit": "kg", "amount": 13.45, "destination": "material" }, { - "name": "이형철근 D16", + "name": "이형철근", + "spec": "D16", "unit": "kg", "amount": 30.42, "destination": "material" @@ -176,31 +195,44 @@ "unit": "개소", "source": "uljin_library", "source_note": "§2 집수정 Ø800 — 내부 3.0×1.0×1.2, 벽 0.2, 바닥기초 3.4×1.4×0.2", + "section": { + "note": "치수(m) — 소광리 07-구조도 「집수정구조도(800)」 설계조건 칸(AB4~AE17)과 산출식 Y21~Y30 · 같은 탭 입체도. 관·방수로 자리는 원문 입체도 모식(산출식에 자리 치수 없음)", + "inner": { "length_m": 3.0, "width_m": 1.0, "height_m": 1.2 }, + "wall_m": 0.2, + "base_m": 0.2, + "pipe_d_m": 0.8, + "spillway": { "count": 3, "top_m": 1.0, "bottom_m": 0.4, "depth_m": 0.3 }, + "dig": { "extra_length_m": 0.4, "width_m": 2.0 } + }, "components": [ { "name": "콘크리트", "unit": "㎥", - "amount": 2.84, - "destination": "unit_price" + "amount": 2.83752, + "destination": "unit_price", + "basis_note": "원문 값(라이브러리 표기 2.84)" }, { "name": "합판거푸집", "unit": "㎡", - "amount": 21.28, - "destination": "unit_price" + "amount": 21.2843, + "destination": "unit_price", + "basis_note": "원문 값(라이브러리 표기 21.28)" }, { - "name": "이형철근 D13", + "name": "이형철근", + "spec": "D13", "unit": "kg", - "amount": 4.78, - "destination": "material" + "amount": 4.776, + "destination": "material", + "basis_note": "원문 값(라이브러리 표기 4.78)" }, { "name": "면목", "unit": "m", - "amount": 12.67, + "amount": 12.6728, "destination": "material", - "basis_note": "A25" + "basis_note": "A25 · 원문 값(라이브러리 표기 12.67)" }, { "name": "터파기", @@ -230,6 +262,13 @@ "unit": "㎡", "source": "uljin_compare", "source_note": "콘크리트포장 T=20cm /㎡", + "section": { + "note": "단면 — 두께는 규격(thickness_cm) · 와이어메쉬 자리는 소광리 「포장」 그림 「WIRE MASH (콘크리트 2/3T 지점에 설치)」", + "mesh_from_bottom_ratio": 0.6667 + }, + "notes": [ + "⚠ 원문 둘이 다름 — 소광리 「포장」 ㎡당: 와이어매쉬 1.0 · 비닐 2㎡(깔기+덮기) ↔ 이 표(울진 1공구 집계): 와이어메쉬 1.16 · 비닐 없음 · 고르지 않음(확인 대기)" + ], "components": [ { "name": "레미콘", diff --git a/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json b/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json index 6c3bf199..63b4e1ef 100644 --- a/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json +++ b/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json @@ -94,11 +94,24 @@ "mismatch_reason": "층따기 길이(깊이)가 아직 입력되지 않았습니다 — 산출 조건에서 넣으면 면적 × 길이로 물량이 섭니다(면적은 이미 섰습니다).", "mismatch_kind": "input_missing" }, + { + "group": "면고르기", + "work_item_code": "FP-09-19-01", + "master_name": "면고르기 > 토사면 고르기", + "variant_axis": "face_dressing_fill_class", + "variant_from": "face_dressing_fill_class", + "variant_missing_reason": "면고르기 성토면 시공·토질이 아직 입력되지 않았습니다 — 산출 조건에서 고르면 단가가 섭니다(품셈 9-19-1 성토면: 인력 점토·모래 2갈래 · 기계 1갈래)", + "note": "성토면 줄(지반 없음) — 2026-09-14 브레인 판정: 밑수 = 초류종자살포 면적(Ⓐ) · 시공·토질은 산출 조건 칸 · 제안값 없음(Ⓒ)." + }, { "group": "면고르기", "ground": "토사", "work_item_code": "FP-09-19-01", - "master_name": "면고르기 > 토사면 고르기" + "master_name": "면고르기 > 토사면 고르기", + "variant_axis": "face_dressing_cut_class", + "variant_from": "face_dressing_cut_class", + "variant_missing_reason": "면고르기 절토면 토질이 아직 입력되지 않았습니다 — 산출 조건에서 고르면 단가가 섭니다(품셈 9-19-1 절토면 토질 6갈래)", + "note": "절토면 토사 몫 — 사면 조각의 토사/암으로 가름, 암 몫은 흙깎기와 같은 시공법으로 9-19-2·3(2026-09-14 판정 Ⓑ)." }, { "group": "면고르기", @@ -220,6 +233,21 @@ "work_item_code": "FP-12-15", "master_name": "집수정" }, + { + "type_id": "revetment", + "work_item_code": null, + "class_from": "form", + "form_codes": { + "돌쌓기(찰)": "FP-13-04-05", + "돌쌓기(메)": "FP-13-04-02" + }, + "class_note": "기슭막이는 **형태가 돌쌓기면 돌쌓기 식**(`_UnitQuantity_Revetment`) — 공종도 돌쌓기 찰·메와 같음(실무 정본 탭 「돌기슭막이(H=2.0m, 찰쌓기, 기초유)」). 그 밖 형태(콘크리트·돌망태·통나무·바자)는 전개식이 없어 사유로 막힘. 관 유입·유출부 기슭막이와 독립 기슭막이가 이 줄로 섬(A1, 2026-09-14).", + "secondary_axes": [ + "stone_kind" + ], + "billing_component": "돌쌓기", + "variant_axis": "back_len_cm" + }, { "type_id": "ford_pavement", "work_item_code": "FP-12-06", @@ -345,7 +373,7 @@ "from_components": [ "기초잡석" ], - "why": "관측 원단위에는 기초잡석 줄이 없으나 **버림 폭이 곧 잡석다짐 폭**이라(KCS 34 50 05) 버림 물량에서 두께 비로 나온다 — 버림 0.15㎥/m ÷ 0.1 = 폭 1.5m, 두께 0.2m(사용자 확정 3차 ②) ⇒ 0.30㎥/m." + "why": "관측 원단위에는 기초잡석 줄이 없으나 **버림 폭이 곧 잡석다짐 폭**이라(KCS 34 50 05) 버림 물량에서 두께 비로 나온다 — 버림 0.145㎥/m ÷ 0.1 = 폭 1.45m(1.8 − 전단키 0.35), 두께 0.2m(사용자 확정 3차 ②) ⇒ 0.29㎥/m(원문 값 · 2026-09-14 라이브러리 반올림 0.15 에서 바꿈)." } ], "why": "품셈 12장에 「옹벽」 공종이 없음. 실무 내역은 「반중력식옹벽 H=2.0」 한 줄이고 그 일위대가가 위 공종을 묶음.", @@ -414,7 +442,7 @@ "diameter_option_key": "pipe_diameter_mm", "variant_axis": "pipe_diameter_mm", "facility_rule": "⚠ `pipe_points.json` 은 **계곡 통과 시설 전부의 정본**이다(배관·BOX암거·물넘이·세월교·독립 기슭막이). `facility` 가 `pipe` 인 점만 배관이다 — `pipe_diameter_mm` 유무로 가르면 **관경 미지정 관을 놓친다**(2026-09-08 랩탑 창).", - "revetment_note": "⚠ 유출·유입부 기슭막이는 **관 옵션(`inlet_revet_*`·`outlet_revet_*`)이 정본**이다. 레지스트리 `revetment` 타입이 `managed_by: pipe_points` 라 구조물 목록에서 빠졌으므로 **구조물 쪽으로 또 세지 않는다**(2026-08-28 이관).", + "revetment_note": "⚠ 유출·유입부 기슭막이는 **관 옵션(`inlet_revet_*`·`outlet_revet_*`)이 정본**이다. 레지스트리 `revetment` 타입이 `managed_by: pipe_points` 라 구조물 목록에서 빠졌다(2026-08-28 이관). ⭐ **관 줄에는 안 넣고 원단위 전개가 한 번 셈** — B08 이 관 지점을 읽어 「배수관 · 유입부/유출부 기슭막이」 줄로 세움(`Engine_Pipe.facility_structures`, A1 2026-09-14). 관 부설(품셈 12-11 m당: 관·기초콘크리트·거푸집)에 기슭막이 몫이 없어 겹치지 않음. 그전에는 **어느 쪽도 안 세고 있었음**.", "not_ready": { "흄관 밑수 두 벌": "`FP-12-11-02` 는 밑수가 「1 m」와 「1 개소」 두 벌이다(표가 둘). B09 가 `#갈래` 로 두 표를 각각 세우므로 B08 은 `variant_value` 로 어느 쪽인지 보내면 된다.", "터파기·되메우기": "⚠ 관 부설과 터파기·되메우기가 각각 오면 **같은 굴착을 두 번 셀 수 있다**(B09 ㉡ 가드). 관 줄에는 지금 터파기를 붙이지 않는다." diff --git a/resources/data_work_item_master/_manifest.json b/resources/data_work_item_master/_manifest.json index b4ba00c6..9026c366 100644 --- a/resources/data_work_item_master/_manifest.json +++ b/resources/data_work_item_master/_manifest.json @@ -1,7 +1,7 @@ { "schema_version": "1.0", "dataset_id": "data_work_item_master_manifest", - "generated_at": "2026-09-13T23:03:27+09:00", + "generated_at": "2026-09-14T16:09:37+09:00", "built_by": "B08_Quantity/B08_Quantity_Build_WorkItemMaster.py", "source": { "dataset_id": "pum_forest", @@ -12,13 +12,13 @@ "files": [ { "file": "work_item_master_2026-01-01.json", - "sha256": "1463ea14ec2d3d45ec18b3a7e4810333de8e5fc40aaeb0dd329c68f85c863e33", - "size_bytes": 835659 + "sha256": "a942f7ad412441ba0c7ad3f8149fd143b10502dae5d20053e5d98029ab31c6b4", + "size_bytes": 838243 }, { "file": "form_undetermined_2026-01-01.json", - "sha256": "c835699596bf57ffbda9f72a773a72116ebe48c2a2f79ae232ba5784ec9842c7", - "size_bytes": 6447 + "sha256": "6b3476810ddee395856b997fa524a0410a5001fe47511b989c1db2feb82dccd2", + "size_bytes": 6789 }, { "file": "basis_missing_2026-01-01.json", diff --git a/resources/data_work_item_master/form_undetermined_2026-01-01.json b/resources/data_work_item_master/form_undetermined_2026-01-01.json index 33c46bed..5d4ca169 100644 --- a/resources/data_work_item_master/form_undetermined_2026-01-01.json +++ b/resources/data_work_item_master/form_undetermined_2026-01-01.json @@ -139,7 +139,7 @@ }, { "pum_table_id": "F0248", - "section": "8-2-13 대형브레이커(2025)」, 「건설공사 표준품셈 공통부문, 1-2-3 토질. 3. 체적환산계수적용(2025)」 참조", + "section": "9-7-1. T=30㎝ 미만", "headers": [ "구 분", "적 용" @@ -156,6 +156,31 @@ ], "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" }, + { + "pum_table_id": "F0257", + "section": "9-11-1. 콘크리트(기계)", + "headers": [ + "구 분", + "단 위", + "적 용", + "비 고" + ], + "first_rows": [ + [ + "거리(L)", + "km", + "0.1", + "N=(V×T)/(120×L+V.t)= 회/일 Q = N×q = ℓ/일" + ], + [ + "운반속도(V)", + "km/hr", + "2.5", + "" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, { "pum_table_id": "F0304", "section": "10-6-3. 기타 임업자재", @@ -348,7 +373,7 @@ }, { "pum_table_id": "F0356", - "section": "12-2 표면 마무리를 따른다.", + "section": "12-17-1. 펌프카 타설", "headers": [ "슬 럼 프", "기준 시공량" diff --git a/resources/data_work_item_master/work_item_master_2026-01-01.json b/resources/data_work_item_master/work_item_master_2026-01-01.json index 880bf1bc..625475cf 100644 --- a/resources/data_work_item_master/work_item_master_2026-01-01.json +++ b/resources/data_work_item_master/work_item_master_2026-01-01.json @@ -3,7 +3,7 @@ "dataset_id": "work_item_master_forest", "effective_date": "2026-01-01", "pum_edition": "2026-01-01", - "generated_at": "2026-09-13T23:03:27+09:00", + "generated_at": "2026-09-14T16:09:37+09:00", "dataset_version": { "dataset_id": "pum_forest", "effective_date": "2026-01-01", @@ -19,22 +19,14 @@ "stats": { "toc_nodes": 477, "tables_total": 475, - "tables_attached": 456, - "tables_orphan": 19, - "form_undetermined": 13, + "tables_attached": 458, + "tables_orphan": 17, + "form_undetermined": 14, "basis_found": 204, "basis_missing": 122, "basis_grouped": 48 }, "orphan_tables": [ - { - "pum_table_id": "F0248", - "section": "8-2-13 대형브레이커(2025)」, 「건설공사 표준품셈 공통부문, 1-2-3 토질. 3. 체적환산계수적용(2025)」 참조" - }, - { - "pum_table_id": "F0257", - "section": "1-6-6 포장줄눈 절단(2025)」 참조한다." - }, { "pum_table_id": "F0364", "section": "12-17-3. 무근진동기 제외" @@ -144,77 +136,7 @@ "level": 3, "parent_code": "FP-01-01", "sort_order": 1024, - "tables": [ - { - "pum_table_id": "F0152", - "section": "1-1-2 분뜨기묘) 15,000본을 80㎞ 운반할 경우의 대운반비 산출", - "source_line": 3049, - "pum_form": "reference", - "form_basis": "품셈 제1장(적용기준)", - "basis_quantity": null, - "basis_unit": null, - "basis_source": null, - "resource_shares": {}, - "partial_ratio": false, - "expression_cells": [], - "crew_table": false, - "spaced_names": [], - "formula_rows": [], - "special_glyphs": [], - "capacity_formula_here": false, - "variant_key": [], - "condition_note": [ - "규 격", - "수종 및 묘령(형태)", - "적재량", - "비 고" - ], - "raw_row": [ - [ - "2.5톤 차량", - "5.0톤 차량", - "", - "", - "" - ], - [ - "소 묘", - "소나무 1-1(노지묘)", - "110곤포", - "200곤포", - "500본/곤포" - ], - [ - "중 묘", - "편백 2-0(용기묘)", - "150박스", - "300박스", - "100본/박스" - ], - [ - "대 묘", - "해송 1-1-2(노지묘)", - "1,800본", - "3,000본", - "분뜨기묘 1본" - ], - [ - "소 2-2 (용기묘)", - "300곤포", - "800곤포", - "10본/곤포", - "" - ], - [ - "※ 묘목 1,000본당 무게 - 소나무 노지묘 1-1 (45㎏) - 낙엽송, 편백 용기묘 2-0 (200㎏) - 상수리 용기묘 1-0 (200㎏)", - "", - "", - "", - "" - ] - ] - } - ], + "tables": [], "variant_keys": [] }, { @@ -16769,6 +16691,75 @@ "" ] ] + }, + { + "pum_table_id": "F0152", + "section": "5-34. 묘목 소운반", + "source_line": 3049, + "pum_form": "reference", + "form_basis": "헤더 '적재량'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [], + "condition_note": [ + "규 격", + "수종 및 묘령(형태)", + "적재량", + "비 고" + ], + "raw_row": [ + [ + "2.5톤 차량", + "5.0톤 차량", + "", + "", + "" + ], + [ + "소 묘", + "소나무 1-1(노지묘)", + "110곤포", + "200곤포", + "500본/곤포" + ], + [ + "중 묘", + "편백 2-0(용기묘)", + "150박스", + "300박스", + "100본/박스" + ], + [ + "대 묘", + "해송 1-1-2(노지묘)", + "1,800본", + "3,000본", + "분뜨기묘 1본" + ], + [ + "소 2-2 (용기묘)", + "300곤포", + "800곤포", + "10본/곤포", + "" + ], + [ + "※ 묘목 1,000본당 무게 - 소나무 노지묘 1-1 (45㎏) - 낙엽송, 편백 용기묘 2-0 (200㎏) - 상수리 용기묘 1-0 (200㎏)", + "", + "", + "", + "" + ] + ] } ], "variant_keys": [] @@ -22444,1822 +22435,6 @@ "100" ] ] - }, - { - "pum_table_id": "F0452", - "section": "8-2-3 굴착기(2025)」를 참조하여 적용계수를 달리 적용하도록 한다.", - "source_line": 7823, - "pum_form": "reference", - "form_basis": "헤더 '할인'", - "basis_quantity": null, - "basis_unit": null, - "basis_source": null, - "resource_shares": {}, - "partial_ratio": false, - "expression_cells": [], - "crew_table": false, - "spaced_names": [], - "formula_rows": [], - "special_glyphs": [], - "capacity_formula_here": false, - "variant_key": [], - "condition_note": [ - "번호", - "할인․할증 요소", - "할인․할증 반영이 필요한 공종" - ], - "raw_row": [ - [ - "사업 구분", - "세 부 공 종", - "", - "", - "", - "" - ], - [ - "1-4-1", - "작업시기", - "숲가꾸기", - "6-4-1 덩굴걷기", - "6-4-2 덩굴약제 살포처리", - "6-4-3 소금처리" - ], - [ - "6-4-4 뿌리제거", - "6-5 어린나무가꾸기", - "", - "", - "", - "" - ], - [ - "1-4-2", - "조림 후 경과연수", - "풀베기", - "6-2-2 풀베기(줄베기)", - "", - "" - ], - [ - "1-4-3", - "집단화정도", - "숲가꾸기", - "3-1 경계표시", - "4-2-1 단목베기", - "6-2-1 풀베기(둘레베기)" - ], - [ - "6-2-2 풀베기(줄베기)", - "6-2-3 풀베기(모두베기)", - "6-3 맹아제거", - "", - "", - "" - ], - [ - "6-4-1 덩굴걷기", - "6-4-2 덩굴약제 살포처리", - "6-4-3 소금처리", - "", - "", - "" - ], - [ - "6-4-4 뿌리제거", - "6-5 어린나무가꾸기", - "6-6 가지치기 및 수형교정", - "", - "", - "" - ], - [ - "1-4-4", - "작업구역", - "병해충방제", - "8-6-2 드론방제", - "", - "" - ], - [ - "1-4-5", - "경사도 (산지경사)", - "조림", - "3-5 예정지정리작업", - "5-3-1 나무식재", - "5-4 파종조림" - ], - [ - "5-5천연하종갱신", - "5-6 움싹갱신", - "5-7 생태보완조림", - "", - "", - "" - ], - [ - "6-1 비료주기", - "", - "", - "", - "", - "" - ], - [ - "숲가꾸기", - "3-1 경계표시", - "3-3 작업로 설치", - "3-6 산물 임내정리", - "", - "" - ], - [ - "4-2-1 단목베기", - "6-2-1 풀베기(둘레베기)", - "6-2-2 풀베기(줄베기)", - "", - "", - "" - ], - [ - "6-2-3 풀베기(모두베기)", - "6-3 (맹아제거)", - "6-4-1 덩굴걷기", - "", - "", - "" - ], - [ - "6-4-2 덩굴약제 살포처리", - "6-4-3 소금처리", - "6-4-4 뿌리제거", - "", - "", - "" - ], - [ - "6-5 어린나무가꾸기", - "6-6 가지치기 및 수형교정", - "7-1 인력집재", - "", - "", - "" - ], - [ - "7-2 수라집재", - "7-3 아키야윈치 (임업용 윈치)", - "7-4-2 소형가선집재 (2드럼윈치)", - "", - "", - "" - ], - [ - "7-5-2 트랙터부착형집재(지면끌기)", - "7-6 스윙야더 집재", - "7-7-2 HAM200 집재(소형가선)", - "", - "", - "" - ], - [ - "7-9-2 부탁형 타워 (K-301, HAM300)", - "7-15-2 소형 포워더 운재", - "", - "", - "", - "" - ], - [ - "국유임산물 (수확)", - "4-1-1 수확베기 (임업용 동력기계톱)", - "7-4-1 소형가선집재 (2드럼윈치)", - "7-5-1 트랙터부착형집재기", - "", - "" - ], - [ - "7-6 스윙야더 집재", - "7-7-1 HAM200 (춘천․스마트)", - "7-8-3 타워야더 (RME-300T)", - "", - "", - "" - ], - [ - "7-9-1 부탁형 타워 (K-301, HAM300)", - "7-11 동력상하차기 (우드그래플)집재", - "", - "", - "", - "" - ], - [ - "산림병해충방제", - "3-3 작업로 설치", - "4-2-1 단목베기", - "3-6 산물 임내정리", - "", - "" - ], - [ - "7-1-2 인력집재", - "3-7 재해산물수집", - "7-2 수라집재", - "", - "", - "" - ], - [ - "7-3 아키야윈치 (임업용 윈치)", - "7-4-2 소형가선집재 (2드럼윈치)", - "7-5-2 트랙터부착형집재(지면끌기)", - "", - "", - "" - ], - [ - "7-7-2 HAM200 집재(소형가선)", - "7-8-3 타워야더 (RME-300T)", - "7-10 굴착기 우드그랩 산지집재", - "", - "", - "" - ], - [ - "8-1-1 나무주사", - "8-4 끈끈이 롤 트랩", - "", - "", - "", - "" - ], - [ - "소나무재선충병 방제", - "3-3 작업로 설치", - "4-2-1 단목베기", - "7-3 아키야윈치 (임업용 윈치)", - "", - "" - ], - [ - "7-4-2 소형가선집재 (2드럼윈치)", - "7-5-2 트랙터부착형집재(지면끌기)", - "7-7-2 HAM200 집재(소형가선)", - "", - "", - "" - ], - [ - "7-9-2 부착형 타워 (K-301, HAM300)", - "8-1-1 나무주사", - "8-6-2 드론방제", - "", - "", - "" - ], - [ - "1-4-6", - "작업장까지의 이동거리", - "조림", - "3-5 예정지정리작업", - "5-3-1 나무식재", - "5-4 파종조림" - ], - [ - "5-5 천연하종갱신", - "5-6 움싹갱신", - "5-7 생태보완조림", - "", - "", - "" - ], - [ - "6-1 비료주기", - "", - "", - "", - "", - "" - ], - [ - "1-4-7", - "하층식생", - "숲가꾸기", - "6-4-3 소금처리", - "6-4-4 뿌리제거", - "" - ], - [ - "1-4-8", - "장애물의 정도", - "숲가꾸기", - "3-2 작업로 선정", - "4-2-1 단목베기", - "6-6 가지치기 및 수형교정" - ], - [ - "7-1-2 인력집재", - "7-3 아키야윈치 (임업용 윈치)", - "7-4-2 소형가선집재 (2드럼윈치)", - "", - "", - "" - ], - [ - "국유임산물 (수확)", - "4-1-1 수확베기 (임업용 동력기계톱)", - "", - "", - "", - "" - ], - [ - "산림병해충방제", - "4-2-1 단목베기", - "3-7 재해산물수집", - "7-1-2 인력집재", - "", - "" - ], - [ - "7-3 아키야윈치 (임업용 윈치)", - "7-4-2 소형가선집재 (2드럼윈치)", - "8-1-1 나무주사", - "", - "", - "" - ], - [ - "소나무재선충병 방제", - "3-7 재해산물수집", - "4-2-1 단목베기", - "7-1-2 인력집재", - "", - "" - ], - [ - "7-3 아키야윈치 (임업용 윈치)", - "7-4-2 소형가선집재 (2드럼윈치)", - "8-3 소각, 매몰, 훈증, 박피", - "", - "", - "" - ], - [ - "1-4-9", - "제거대상 식생", - "숲가꾸기", - "3-3 작업로 설치", - "", - "" - ], - [ - "산림병해충방제", - "3-3 작업로 설치", - "", - "", - "", - "" - ], - [ - "소나무재선충병 방제", - "3-3 작업로 설치", - "", - "", - "", - "" - ], - [ - "1-4-10", - "토양상태 (토양조건)", - "조림", - "5-3-1 나무식재", - "5-4 파종조림", - "5-5 천연하종갱신" - ], - [ - "5-7 생태보완조림", - "6-1 비료주기", - "", - "", - "", - "" - ], - [ - "숲가꾸기", - "6-4-4 뿌리제거", - "", - "", - "", - "" - ], - [ - "1-4-11", - "덩굴피복도", - "숲가꾸기", - "6-4-1 덩굴걷기", - "", - "" - ], - [ - "1-4-12", - "제거대상 피복도", - "어린나무가꾸기", - "6-5 어린나무가꾸기", - "", - "" - ], - [ - "1-4-13", - "주행 장애물 상태", - "숲가꾸기", - "7-15-2 초소형포워더운재", - "", - "" - ], - [ - "국유임산물 (수확)", - "7-15-1 초소형포워더운재", - "7-16 소형포워더 운재", - "7-17 소형트럭 운재", - "", - "" - ], - [ - "7-18 지조운반 (포워더 활용)", - "", - "", - "", - "", - "" - ], - [ - "산림병해충방제", - "7-10 굴착기 우드그랩 산지집재", - "7-15-2 초소형포워더운재", - "", - "", - "" - ], - [ - "소나무재선충병 방제", - "7-15-2 초소형포워더운재", - "", - "", - "", - "" - ], - [ - "1-4-14", - "집재방향", - "숲가꾸기", - "7-5-2 트랙터부착형집재(지면끌기)", - "7-7-2 HAM200 집재(소형가선)", - "" - ], - [ - "국유임산물 (수확)", - "7-4-1 소형가선집재 (2드럼윈치)", - "7-5-1 트랙터부착형집재(지면끌기)", - "7-6 스윙야더 집재", - "", - "" - ], - [ - "7-7-1 HAM200 집재(소형가선)", - "7-8-3 타워야더 (RME-300T)", - "7-9-1 부착형 타워 (K-301, HAM300)", - "", - "", - "" - ], - [ - "산림병해충방제", - "7-5-2 트랙터부착형집재(지면끌기)", - "7-7-2 HAM200 집재(소형가선)", - "7-8-3 타워야더 (RME-300T)", - "", - "" - ], - [ - "소나무재선충병 방제", - "3-7 재해산물수집", - "7-1-2 인력집재", - "7-5-2 트랙터부착형집재(지면끌기)", - "", - "" - ], - [ - "7-7-2 HAM200 집재(소형가선)", - "7-9-2 부착형 타워 (K-301, HAM300)", - "8-8-1 훈증더미제거 (인력)", - "", - "", - "" - ], - [ - "1-4-15", - "횡단운반거리 (측방집재거리)", - "숲가꾸기", - "7-2 수라집재", - "7-7-2 HAM200 집재(소형가선)", - "7-9-2 부착형 타워 (K-301, HAM300)" - ], - [ - "국유임산물 (수확)", - "7-4-1 소형가선집재 (2드럼윈치)", - "7-6 스윙야더 집재", - "7-7-1 HAM200 집재(소형가선)", - "", - "" - ], - [ - "7-8-3 타워야더 (RME-300T)", - "7-9-1 부착형 타워 (K-301, HAM300)", - "", - "", - "", - "" - ], - [ - "산림병해충방제", - "7-7-2 HAM200 집재(소형가선)", - "7-8-3 타워야더 (RME-300T)", - "", - "", - "" - ], - [ - "소나무재선충병 방제", - "7-2 수라집재", - "7-7-2 HAM200 집재(소형가선)", - "7-9-2 부착형 타워 (K-301, HAM300)", - "", - "" - ], - [ - "1-4-16", - "규격재 생산", - "숲가꾸기", - "4-2-1 단목베기", - "", - "" - ], - [ - "1-4-17", - "방제대상목 분포", - "산림병해충방제", - "4-2-1 단목베기", - "8-1-1 나무주사", - "8-4 끈끈이 롤 트랩" - ], - [ - "소나무재선충병 방제", - "8-3 소각, 매몰, 훈증, 박피", - "", - "", - "", - "" - ], - [ - "1-4-18", - "방제대상목 평균 경급", - "산림병해충방제", - "7-3 아키야윈치 (임업용 윈치)", - "7-4-2 소형가선집재 (2드럼윈치)", - "" - ], - [ - "소나무재선충병 방제", - "7-3 아키야윈치 (임업용 윈치)", - "7-4-2 소형가선집재 (2드럼윈치)", - "8-10 그물망 피복", - "", - "" - ], - [ - "1-4-19", - "매개충 나무주사", - "소나무재선충병 방제", - "8-1-1 나무주사", - "", - "" - ], - [ - "1-4-20", - "방제지 사면", - "소나무재선충병 방제", - "8-6-2 드론방제", - "", - "" - ], - [ - "1-4-21", - "방제지 접근성", - "소나무재선충병 방제", - "8-6-2 드론방제", - "", - "" - ], - [ - "1-4-22", - "방제 수종", - "소나무재선충병 방제", - "8-3 소각, 매몰, 훈증, 박피", - "", - "" - ], - [ - "1-4-23", - "방제목 운반거리", - "소나무재선충병 방제", - "8-10 그물망 피복", - "", - "" - ], - [ - "1-4-24", - "방제장비 규격", - "소나무재선충병 방제", - "8-8-2 훈증더미제거 (기계)", - "", - "" - ], - [ - "1-4-25", - "작업시간 제한", - "모든 사업", - "모든 공종", - "", - "" - ], - [ - "1-4-26", - "소규모 작업물량 제한", - "", - "", - "", - "" - ] - ] - }, - { - "pum_table_id": "F0453", - "section": "8-2-3 굴착기(2025)」를 참조하여 적용계수를 달리 적용하도록 한다.", - "source_line": 7907, - "pum_form": "reference", - "form_basis": "사람 판정 — 〔부록 2〕 산림사업별 적용 가능 공종 목록 — 절 경계가 밀려 붙음", - "basis_quantity": null, - "basis_unit": null, - "basis_source": null, - "resource_shares": {}, - "partial_ratio": false, - "expression_cells": [], - "crew_table": false, - "spaced_names": [], - "formula_rows": [], - "special_glyphs": [], - "capacity_formula_here": false, - "variant_key": [], - "condition_note": [ - "사업종", - "구분", - "적용 가능 공종" - ], - "raw_row": [ - [ - "공종", - "", - "", - "", - "", - "" - ], - [ - "조림", - "시행 지침", - "3-5 예정지 정리작업", - "5-3-1 나무식재", - "5-30 표시봉설치", - "5-31 지주목설치" - ], - [ - "5-32 대절작업", - "5-33 묘목 가식작업", - "5-34 묘목 소운반", - "5-4 파종조림", - "", - "" - ], - [ - "5-5 천연하종갱신", - "5-6 움싹갱신", - "5-7 생태보완조림", - "5-8 큰나무 공익조림", - "", - "" - ], - [ - "6-1 비료주기", - "", - "", - "", - "", - "" - ], - [ - "지침 외", - "5-1-1 관목굴취", - "5-1-2 교목굴취(나무높이)", - "5-1-3 교목굴취(근원직경)", - "5-2 뿌리돌림", - "" - ], - [ - "5-3-2 관목식재(단식)", - "5-3-3 관목식재(군식)", - "5-3-4 교목식재 (나무높이)", - "5-3-5 교목식재 (흉고직경)", - "", - "" - ], - [ - "10-13 드론운반공", - "", - "", - "", - "", - "" - ], - [ - "숲가 꾸기", - "시행 지침", - "3-1 경계표시", - "3-2 작업로 선정", - "3-3 작업로 설치", - "6-2-1 둘러베기 (풀베기)" - ], - [ - "6-2-2 줄베기 (풀베기)", - "6-2-3 모두베기 (풀베기)", - "6-3 맹아제거", - "6-4-1 덩굴걷기", - "", - "" - ], - [ - "6-4-2 덩굴 약제 살포처리", - "6-4-3 소금처리", - "6-4-4 뿌리제거", - "6-5어린나무 가꾸기", - "", - "" - ], - [ - "4-2-1 단목베기", - "4-3 위험목 베기", - "6-6 가지치기 및 수형교정", - "7-1-2 인력집재", - "", - "" - ], - [ - "7-3아키야윈치 (임업용 윈치)", - "7-4-2 소형가선집재 (2드럼윈치)", - "7-2 수라집재", - "7-5-2 트랙터부착형집재(지면끌기)", - "", - "" - ], - [ - "7-15-2 초소형포워더 운재", - "7-7-2 HAM200 집재(소형가선)", - "7-8-1 가선설치 (중형가선)", - "7-8-2 가선해체 (중형가선)", - "", - "" - ], - [ - "7-9-2 부착형타워 K-301, HAM300 (중형가선)", - "7-12 동력상하차기 (우드그래플) 집적", - "3-6 산물 임내정리", - "", - "", - "" - ], - [ - "지침 외", - "3-5 드론 영상 촬영", - "4-5 벌도 위험목 점검", - "4-6 벌목부 작업안전 보조", - "7-13 검척", - "" - ], - [ - "국유 임산물 수확", - "시행 지침", - "4-1-1 수확베기", - "4-1-2 하베스터 베기", - "7-3아키야윈치 (임업용 윈치)", - "7-5-1 트랙터부착형집재(지면끌기)" - ], - [ - "7-4-1 소형가선집재 (2드럼윈치)", - "7-6 스윙야더 집재", - "7-8-1 가선설치 (중형가선)", - "7-8-2 가선해체 (중형가선)", - "", - "" - ], - [ - "7-8-3 타워야더 RME-300T", - "7-7-1 HAM200 집재(소형가선)", - "7-9-1 부착형 타워 (K-301, HAM300)", - "7-11 동력상하차기 (우드그래플) 집재", - "", - "" - ], - [ - "7-1-1 인력집재 (수확)", - "7-15-1 초소형포워더 운재", - "7-16 소형포워더 운재", - "7-17 소형트럭 운재", - "", - "" - ], - [ - "7-18 지조운반 (포워더 활용)", - "7-12 동력상하차기 (우드그래플) 집적", - "7-13 검척", - "7-14 원목 운반 (수확)", - "", - "" - ], - [ - "3-4-2 임산물 운반로 및 작업로 신설", - "3-4-3 임산물 운반로 및 작업로 보수", - "", - "", - "", - "" - ], - [ - "지침 외", - "4-5 벌도 위험목 점검", - "4-6 벌목부 작업안전 보조", - "", - "", - "" - ], - [ - "산림 병해충 방제", - "시행 지침", - "3-3 작업로 설치", - "4-2-1 단목베기", - "3-6 산물 임내정리", - "8-1-1 나무주사" - ], - [ - "8-1-2 약제주입병", - "8-2-2 약제주입기준 (솔잎혹파리)", - "8-2-3 약제주입기준 (솔껍질깍지벌레)", - "8-2-4 약제주입기준 (솔나방)", - "", - "" - ], - [ - "8-2-5 약제주입기준 (푸사리움가지마름병)", - "8-3 소각, 매몰, 훈증, 박피", - "8-11 이동식 임목파쇄", - "8-4 끈끈이 롤 트랩", - "", - "" - ], - [ - "7-1-2 인력집재", - "3-4 재해산물 수집", - "7-3아키야윈치 (임업용 윈치)", - "7-4-2 소형가선집재 (2드럼윈치)", - "", - "" - ], - [ - "7-2 수라집재", - "7-5-2 트랙터부착형집재(지면끌기)", - "7-16 소형포워더 운재", - "7-7-2 HAM200 집재(소형가선)", - "", - "" - ], - [ - "7-8-1 가선설치 (중형가선)", - "7-8-2 가선해체 (중형가선)", - "7-8-3 타워야더 RME-300T", - "7-12 동력상하차기 (우드그래플)집적", - "", - "" - ], - [ - "7-10 굴착기 우드그랩 산지집재", - "4-3 위험목 베기", - "8-6-1 유인헬기방제", - "8-6-3 지상방제", - "", - "" - ], - [ - "지침 외", - "3-5 드론 영상 촬영", - "4-5 벌도 위험목점검", - "4-6 벌목부 작업안전 보조", - "7-13 검척", - "" - ], - [ - "소나무 재선충병 방제", - "시행 지침", - "3-3 작업로 설치", - "8-1-1 나무주사", - "8-1-2 약제주입병", - "8-2-1 약제주입기준 (소나무재선충병)" - ], - [ - "8-6-2 드론방제", - "8-6-3 지상방제", - "8-5 페르몬 유인트랩 (매개충 유인트랩)", - "4-1 수확베기 (모두베기)", - "", - "" - ], - [ - "8-3 소각, 매몰, 훈증, 박피", - "4-2-1 단목베기 (강도간벌)", - "8-9 잔가지 줍기", - "8-11 이동식 임목 파쇄", - "", - "" - ], - [ - "8-10 그물망 피복", - "8-8-1 훈증더미 제거 (인력)", - "8-8-2 훈증더미 제거 (기계)", - "4-3 위험목 베기", - "", - "" - ], - [ - "8-7 방제 실행등록", - "7-1-2 인력집재", - "7-3아키야윈치 (임업용 윈치)", - "7-4-2 소형가선집재 (2드럼윈치)", - "", - "" - ], - [ - "7-2 수라집재", - "7-5-2 트랙터부착형집재(지면끌기)", - "7-16 소형포워더 운재", - "7-7-2 HAM200 집재(소형가선)", - "", - "" - ], - [ - "7-8-1 가선설치 (중형가선)", - "7-8-2 가선해체 (중형가선)", - "7-8-3 타워야더 RME-300T", - "7-9-2 부착형 타워 (K-301, HAM300)", - "", - "" - ], - [ - "7-12 동력상하차기 (우드그래플)집적", - "7-10 굴착기 우드그랩 산지집재", - "", - "", - "", - "" - ], - [ - "지침 외", - "3-5 드론 영상 촬영", - "4-4 가지정리", - "4-5 벌도 위험목점검", - "4-6 벌목부 작업안전 보조", - "" - ], - [ - "임도, 사방,복원", - "품셈", - "4-2-2 단목베기 (1,000㎡)", - "5-1-4 떼채취", - "5-9 해안조림", - "5-10 사방조림" - ], - [ - "5-11 사초심기", - "5-12 떼붙임(재배잔디)", - "5-13 떼심기", - "5-14 새심기", - "", - "" - ], - [ - "5-15 바자얽기", - "5-16 선떼붙이기공", - "5-17 조공", - "5-18 씨뿌리기(줄)", - "", - "" - ], - [ - "5-19 표토 절취 및 정지", - "5-20 표토관리", - "5-21 표토이식", - "5-22 평떼", - "", - "" - ], - [ - "5-23 줄떼", - "5-24 씨앗뿜어붙이기", - "5-25 거적덮기", - "5-26 흙갈이", - "", - "" - ], - [ - "5-27 식재면 관리", - "5-28 비탈덮기", - "5-29 복사이식", - "9-1 굴착", - "", - "" - ], - [ - "9-2 노선 굴진보조원", - "9-3 토사깍기", - "9-4 암절취", - "9-5 발파암", - "", - "" - ], - [ - "9-6 발파암 소할", - "9-7 무근콘크리트 깨기", - "9-8 철근콘크리트깨기", - "9-9 석축헐기", - "", - "" - ], - [ - "9-10 기존포장 깨기", - "9-11 포장절단", - "9-12 측구터파기", - "9-13 구조물터파기", - "", - "" - ], - [ - "9-14 되메우기 및 다짐", - "9-15 표토제거", - "9-16 노체", - "9-17 다짐", - "", - "" - ], - [ - "9-18 층따기", - "9-19 면고르기", - "9-20 뿌리다듬기 및 적재", - "9-21 제근", - "", - "" - ], - [ - "9-22 섞기", - "10-1 시멘트운반", - "10-2 철근운반", - "10-3 골재운반", - "", - "" - ], - [ - "10-4 중기운반", - "10-5 레미콘 운반비 산정", - "10-6 인력운반", - "10-7 모노레일 운반", - "", - "" - ], - [ - "10-8 케이블 크레인 운반", - "10-9 덤프트럭 운반", - "10-10 헬리콥터 자재 운반", - "10-11 불도저 운반", - "", - "" - ], - [ - "10-12 덤프 운반", - "11-1 콘테이너형 가설건축물", - "11-2 토공의 비탈 규준틀", - "11-3 수평규준틀", - "", - "" - ], - [ - "11-4 쇄석․혼합석 부설", - "12-1 콘크리트타설", - "12-2 표면 마무리", - "12-3 철근 현장가공 및 조림", - "", - "" - ], - [ - "12-4 합판거푸집", - "12-5 문양거푸집", - "12-6 콘크리트 포장 (인력시공)", - "12-7 포장절단 및 줄눈설치", - "", - "" - ], - [ - "12-8 콘크리트 포장 거푸집", - "12-9 측구", - "12-10 맹암거", - "12-11 관부설", - "", - "" - ], - [ - "12-12 날개벽", - "12-13 면벽", - "12-14 가배수관", - "12-15 집수정", - "", - "" - ], - [ - "12-16 맨홀", - "12-17 펌프카", - "12-18 철근가공조립 (복잡)", - "12-19 강관비계", - "", - "" - ], - [ - "12-20 강관동바리", - "12-21 아스팔트코팅(2회)", - "12-22 P.V.C 파이프 설치(50㎜)", - "12-23 부직포설치", - "", - "" - ], - [ - "12-24 뒷채움 및 되메우기", - "12-25 기초잡석", - "12-26 물푸기", - "12-27 지수판", - "", - "" - ], - [ - "12-28 신구 BOX접합", - "12-29 스페이셔 설치 (몰탈 블룩)", - "12-30 다웰바 설치", - "12-31 물끊기 홈 (NOTCH) 설치", - "", - "" - ], - [ - "12-32 전선관 설치 (P.V.C ∅54m/m)", - "12-33 비닐깔기", - "12-34 암거이음받침", - "12-35 콘크리트 표면 강화재(하드너)", - "", - "" - ], - [ - "12-36 P.C BOX 설치", - "12-37 콘크리트 타설 (무근진동기 제외)", - "12-38 유로폼", - "13-1 석재 및 골재", - "", - "" - ], - [ - "13-2 채집 및 세척", - "13-3 기초다짐 및 뒷채움", - "13-4 돌쌓기", - "13-5 돌붙임", - "", - "" - ], - [ - "13-6 큰돌쌓기", - "13-7 큰돌붙이기", - "13-8 막돌쌓기", - "13-9 서식지 조성 돌쌓기 및 놓기", - "", - "" - ], - [ - "13-10 말뚝박기공", - "13-11 돌망태", - "13-12 비탈다듬기", - "13-13 흙막이", - "", - "" - ], - [ - "13-14 식생토낭 및 포트", - "13-15 목재공", - "13-16 매트부설", - "14-1 입목뿌리 밑막이", - "", - "" - ], - [ - "14-2 근주이식", - "", - "", - "", - "", - "" - ] - ] - }, - { - "pum_table_id": "F0454", - "section": "8-2-3 굴착기(2025)」를 참조하여 적용계수를 달리 적용하도록 한다.", - "source_line": 7983, - "pum_form": "reference", - "form_basis": "'단가산출서' — 채워 넣으라고 둔 빈 서식", - "basis_quantity": null, - "basis_unit": null, - "basis_source": null, - "resource_shares": {}, - "partial_ratio": false, - "expression_cells": [], - "crew_table": true, - "spaced_names": [], - "formula_rows": [], - "special_glyphs": [ - "∼(U+223C)" - ], - "capacity_formula_here": false, - "variant_key": [], - "condition_note": [ - "ha당 조림 단가산출서(예시)" - ], - "raw_row": [ - [ - "위치 및 면적", - "1-0-1-0 또는 00임반 00소반", - "비 고", - "", - "", - "", - "", - "", - "", - "", - "", - "" - ], - [ - "구분", - "작업량", - "단위품 (인원,수량,요율)", - "소요품", - "단가(원)", - "할인 ㆍ 증률", - "계(원)", - "", - "", - "", - "", - "" - ], - [ - "", - "단위작업", - "", - "단위", - "", - "단위", - "", - "종류", - "", - "", - "", - "" - ], - [ - "단 위 작 업 별", - "1. 예정지정리", - "", - "", - "", - "", - "", - "", - "", - "", - "1,619,278", - "" - ], - [ - "- 직접노무비", - "", - "", - "", - "", - "", - "", - "", - "", - "1,506,543", - "", - "" - ], - [ - "ㆍ벌채부산물 임내정리 (벌채와 동시 정리지역)", - "1.0", - "ha", - "7.00", - "인", - "7.00", - "195,655", - "특별50% 보통50%", - "10%", - "1,506,543", - "2인 1조", - "" - ], - [ - "", - "", - "", - "", - "", - "(221,506)", - "(특별인부)", - "", - "", - "", - "", - "" - ], - [ - "", - "", - "", - "", - "", - "(169,804)", - "(보통인부)", - "", - "", - "", - "", - "" - ], - [ - "- 재료비(체인톱)", - "", - "", - "", - "", - "", - "", - "", - "", - "86,275", - "", - "" - ], - [ - "ㆍ보통휘발유(주연료)", - "3.5", - "대/ha", - "5.60", - "ℓ/대", - "19.60", - "1,537", - "무연(ℓ)", - "", - "30,125", - "", - "" - ], - [ - "ㆍ보통휘발유(잡품)", - "30,125", - "원", - "40", - "%", - "", - "", - "", - "", - "12,050", - "", - "" - ], - [ - "ㆍ체인오일(친환경)", - "3.5", - "대/ha", - "2.10", - "ℓ/대", - "7.35", - "6,000", - "국내산(ℓ)", - "", - "44,100", - "", - "" - ], - [ - "- 기계경비(체인톱)", - "3.5", - "대", - "0.0084", - "", - "", - "900,000", - "체인톱(대)", - "", - "26,460", - "", - "" - ], - [ - "2. 식 재", - "", - "", - "", - "", - "", - "", - "", - "", - "3,379,002", - "", - "" - ], - [ - "- 직접노무비", - "", - "", - "", - "", - "", - "", - "", - "", - "3,006,510", - "", - "" - ], - [ - "ㆍ식재작업", - "3,000", - "본/ha", - "4.00", - "인/천본", - "12.00", - "185,315", - "특별30% 보통70%", - "10%", - "2,446,158", - "소나무 2-0용(중묘)", - "" - ], - [ - "", - "", - "", - "", - "", - "", - "(221,506)", - "(특별인부)", - "", - "", - "", - "" - ], - [ - "", - "", - "", - "", - "", - "", - "(169,804)", - "(보통인부)", - "", - "", - "", - "" - ], - [ - "ㆍ표시봉설치", - "3,000", - "본/ha", - "0.70", - "인/천본", - "2.10", - "169,804", - "보통인부", - "", - "356,588", - "", - "" - ], - [ - "ㆍ소운반", - "3,000", - "본/ha", - "0.40", - "인/천본", - "1.20", - "169,804", - "보통인부", - "", - "203,764", - "", - "" - ], - [ - "- 재료비", - "", - "", - "", - "", - "", - "", - "", - "", - "360,000", - "", - "" - ], - [ - "ㆍ묘목", - "3,000", - "본/ha", - "", - "", - "", - "0원", - "원/본", - "", - "0", - "관급자재", - "" - ], - [ - "ㆍ표시봉", - "3,000", - "개/ha", - "", - "", - "", - "120", - "원/개", - "", - "360,000", - "대나무", - "" - ], - [ - "- 경 비", - "", - "", - "", - "", - "", - "", - "", - "", - "12,492", - "", - "" - ], - [ - "ㆍ대운반", - "3,000", - "본/ha", - "", - "", - "", - "4,164", - "원/1,000본", - "", - "12,492", - "운반 50km", - "" - ], - [ - "합 계", - "순 원 가", - "", - "", - "", - "", - "", - "", - "", - "", - "4,998,280", - "" - ], - [ - "직접노무비", - "", - "", - "", - "", - "", - "", - "", - "", - "4,513,053", - "", - "" - ], - [ - "재 료 비", - "", - "", - "", - "", - "", - "", - "", - "", - "446,275", - "", - "" - ], - [ - "경비(기계경비)", - "", - "", - "", - "", - "", - "", - "", - "", - "38,952", - "", - "" - ], - [ - "<할인․할증률 적용>", - "", - "", - "", - "", - "", - "", - "", - "", - "", - "", - "" - ], - [ - "구 분", - "할인․할증요소", - "단위작업(%)", - "비고", - "", - "", - "", - "", - "", - "", - "", - "" - ], - [ - "예정지정리", - "식재", - "", - "", - "", - "", - "", - "", - "", - "", - "", - "" - ], - [ - "o 벌채구역 크기(1-4-4)", - "개벌지 크기가 3,000㎡ 이상", - "0%", - "", - "", - "", - "", - "", - "", - "", - "", - "" - ], - [ - "o 경사도(1-4-5)", - "중경사(15∼30°)", - "5%", - "5%", - "", - "", - "", - "", - "", - "", - "", - "" - ], - [ - "o 작업장까지 이동거리(1-4-6)", - "1.3㎞ 미만", - "0%", - "0%", - "", - "", - "", - "", - "", - "", - "", - "" - ], - [ - "o 토양상태(1-4-10)", - "돌함량 10∼30%", - "", - "5%", - "", - "", - "", - "", - "", - "", - "", - "" - ], - [ - "o 미이용산림 바이오 메스 정리량", - "50㎥∼100㎥/ha미만", - "5%", - "", - "", - "", - "", - "", - "", - "", - "", - "" - ], - [ - "o 산불피해지", - "", - "0%", - "", - "", - "", - "", - "", - "", - "", - "", - "" - ], - [ - "합 계", - "", - "10%", - "10%", - "", - "", - "", - "", - "", - "", - "", - "" - ] - ] } ], "variant_keys": [] @@ -27153,6 +25328,53 @@ "대형브레이커치즐 0.01본/hr 보통인부 1인/일" ] ] + }, + { + "pum_table_id": "F0248", + "section": "9-7-1. T=30㎝ 미만", + "source_line": 4817, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [ + "1/1.5(0.67)" + ], + "crew_table": false, + "spaced_names": [], + "formula_rows": [ + "K", + "f", + "E" + ], + "special_glyphs": [], + "capacity_formula_here": true, + "variant_key": [], + "condition_note": [ + "구 분", + "적 용" + ], + "raw_row": [ + [ + "K", + "0.55" + ], + [ + "f", + "1/1.5(0.67)" + ], + [ + "E", + "0.35" + ], + [ + "㎝(초)", + "22″(180°)" + ] + ] } ], "variant_keys": [] @@ -27624,6 +25846,71 @@ "운반방법별 적용" ] ] + }, + { + "pum_table_id": "F0257", + "section": "9-11-1. 콘크리트(기계)", + "source_line": 4961, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "×(U+00D7)" + ], + "capacity_formula_here": false, + "variant_key": [], + "condition_note": [ + "구 분", + "단 위", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "거리(L)", + "km", + "0.1", + "N=(V×T)/(120×L+V.t)= 회/일 Q = N×q = ℓ/일" + ], + [ + "운반속도(V)", + "km/hr", + "2.5", + "" + ], + [ + "적재적하시간(t)", + "min", + "4.0", + "" + ], + [ + "일일실작업시간(T)", + "분", + "480분-30분", + "" + ], + [ + "1회 운반량(q)", + "ℓ", + "25", + "" + ], + [ + "1일 운반횟수(N)", + "회", + "", + "" + ] + ] } ], "variant_keys": [] @@ -30064,7 +28351,14 @@ "formula_rows": [], "special_glyphs": [], "capacity_formula_here": false, - "variant_key": [], + "variant_key": [ + "절토면 · 모래ㆍ사질토ㆍ점토ㆍ점질토", + "절토면 · 연질토ㆍ불순자갈", + "절토면 · 호박돌 섞인 고결토ㆍ경질토", + "절토면 · 풍화암", + "절토면 · 연암", + "절토면 · 보통암ㆍ경암" + ], "condition_note": [ "토 질 별", "구 분" @@ -30138,7 +28432,12 @@ "formula_rows": [], "special_glyphs": [], "capacity_formula_here": false, - "variant_key": [], + "variant_key": [ + "성토면 · 인력 · 점토 또는 점질토", + "성토면 · 인력 · 모래 또는 사질토", + "성토면 · 기계 · 무한궤도", + "성토면 · 기계 · 타이어" + ], "condition_note": [ "시 공", "토 질", @@ -30175,7 +28474,18 @@ ] } ], - "variant_keys": [] + "variant_keys": [ + "절토면 · 모래ㆍ사질토ㆍ점토ㆍ점질토", + "절토면 · 연질토ㆍ불순자갈", + "절토면 · 호박돌 섞인 고결토ㆍ경질토", + "절토면 · 풍화암", + "절토면 · 연암", + "절토면 · 보통암ㆍ경암", + "성토면 · 인력 · 점토 또는 점질토", + "성토면 · 인력 · 모래 또는 사질토", + "성토면 · 기계 · 무한궤도", + "성토면 · 기계 · 타이어" + ] }, { "work_item_code": "FP-09-19-02", @@ -33178,307 +31488,9 @@ "0.34" ] ] - }, - { - "pum_table_id": "F0356", - "section": "12-2 표면 마무리를 따른다.", - "source_line": 6520, - "pum_form": "undetermined", - "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", - "basis_quantity": null, - "basis_unit": null, - "basis_source": null, - "resource_shares": {}, - "partial_ratio": false, - "expression_cells": [], - "crew_table": false, - "spaced_names": [], - "formula_rows": [], - "special_glyphs": [ - "~(U+FF5E)" - ], - "capacity_formula_here": false, - "variant_key": [], - "condition_note": [ - "슬 럼 프", - "기준 시공량" - ], - "raw_row": [ - [ - "무근콘크리트", - "철근콘크리트", - "" - ], - [ - "8 ~ 12 cm", - "130", - "125" - ], - [ - "15 cm", - "135", - "130" - ], - [ - "18 cm 이상", - "145", - "140" - ] - ] - }, - { - "pum_table_id": "F0357", - "section": "12-2 표면 마무리를 따른다.", - "source_line": 6534, - "pum_form": "coefficient", - "form_basis": "행 키가 기호뿐 ['f₁']", - "basis_quantity": null, - "basis_unit": null, - "basis_source": null, - "resource_shares": {}, - "partial_ratio": false, - "expression_cells": [], - "crew_table": false, - "spaced_names": [], - "formula_rows": [], - "special_glyphs": [], - "capacity_formula_here": false, - "variant_key": [], - "condition_note": [ - "유 형", - "Type-Ⅰ", - "Type-Ⅱ", - "Type-Ⅲ", - "Type-Ⅳ" - ], - "raw_row": [ - [ - "f₁", - "1.4", - "1.0", - "0.8", - "0.3" - ] - ] - }, - { - "pum_table_id": "F0358", - "section": "12-2 표면 마무리를 따른다.", - "source_line": 6538, - "pum_form": "reference", - "form_basis": "사람 판정 — 시설유형 Type-Ⅰ~Ⅳ 적용 기준 설명", - "basis_quantity": null, - "basis_unit": null, - "basis_source": null, - "resource_shares": {}, - "partial_ratio": false, - "expression_cells": [], - "crew_table": false, - "spaced_names": [], - "formula_rows": [], - "special_glyphs": [], - "capacity_formula_here": false, - "variant_key": [], - "condition_note": [ - "구 분", - "적용 기준" - ], - "raw_row": [ - [ - "Type-Ⅰ", - "매트기초 등 펌프차 작업에 제약이 없는 시설물" - ], - [ - "Type-Ⅱ", - "벽, 기둥, 보, 슬래브. 교대, 교각 등 펌프차 작업에 큰 지장이 없어 일반적인 시공이 가능한 시설물" - ], - [ - "Type-Ⅲ", - "옹벽, 줄기초, 슬래브 없는[월거더 : wall girder] 구조의 기둥과 보 등 펌프차 작업에 제약을 받는 타설 부위가 좁거나 깊은 시설물" - ], - [ - "Type-Ⅳ", - "절·성토부 비탈면에 시공되는 구조물 등 펌프차 작업에 제약이 매우 큰 시설물" - ] - ] - }, - { - "pum_table_id": "F0359", - "section": "12-2 표면 마무리를 따른다.", - "source_line": 6547, - "pum_form": "coefficient", - "form_basis": "행 키가 기호뿐 ['f₁']", - "basis_quantity": null, - "basis_unit": null, - "basis_source": null, - "resource_shares": {}, - "partial_ratio": false, - "expression_cells": [], - "crew_table": false, - "spaced_names": [], - "formula_rows": [], - "special_glyphs": [], - "capacity_formula_here": false, - "variant_key": [], - "condition_note": [ - "유 형", - "Type-Ⅰ", - "Type-Ⅱ", - "Type-Ⅲ" - ], - "raw_row": [ - [ - "f₁", - "1.2", - "1.0", - "0.8" - ] - ] - }, - { - "pum_table_id": "F0360", - "section": "12-2 표면 마무리를 따른다.", - "source_line": 6551, - "pum_form": "reference", - "form_basis": "사람 판정 — 현장조건 Type-Ⅰ~Ⅲ 적용 기준 설명", - "basis_quantity": null, - "basis_unit": null, - "basis_source": null, - "resource_shares": {}, - "partial_ratio": false, - "expression_cells": [], - "crew_table": false, - "spaced_names": [], - "formula_rows": [], - "special_glyphs": [], - "capacity_formula_here": false, - "variant_key": [], - "condition_note": [ - "구 분", - "적용 기준" - ], - "raw_row": [ - [ - "Type-Ⅰ", - "대기 공간이 충분히 넓어 믹서트럭 2대가 병렬로 타설 준비가 가능하며 지속적인 타설을 수행하는 경우" - ], - [ - "Type-Ⅱ", - "믹서트럭이 1대씩 직렬로 대기하며 순차적으로 타설 준비하여 타설하는 일반적인 경우" - ], - [ - "Type-Ⅲ", - "믹서트럭의 대기 공간이 매우 협소하고 진출입 길이가 길어 연속적인 타설이 어려운 경우" - ] - ] - }, - { - "pum_table_id": "F0361", - "section": "12-2 표면 마무리를 따른다.", - "source_line": 6560, - "pum_form": "requirement", - "form_basis": "직종 표기((인)·인부·공)", - "basis_quantity": 1.0, - "basis_unit": "㎥", - "basis_source": "본문", - "resource_shares": {}, - "partial_ratio": false, - "expression_cells": [], - "crew_table": false, - "spaced_names": [], - "formula_rows": [], - "special_glyphs": [], - "capacity_formula_here": false, - "variant_key": [], - "condition_note": [ - "배 합 비", - "재 료", - "손 비 비 기" - ], - "raw_row": [ - [ - "시멘트(kg)", - "모래(㎥)", - "자갈(㎥)", - "콘크리트공(인)", - "보통인부(인)", - "" - ], - [ - "1:2:4", - "320", - "0.45", - "0.90", - "0.9", - "1.0" - ], - [ - "1:3:6", - "220", - "0.47", - "0.94", - "0.9", - "0.9" - ], - [ - "1:4:8", - "170", - "0.48", - "0.96", - "0.9", - "0.7" - ] - ] - }, - { - "pum_table_id": "F0362", - "section": "12-2 표면 마무리를 따른다.", - "source_line": 6569, - "pum_form": "requirement", - "form_basis": "값 단위 '(m)'", - "basis_quantity": null, - "basis_unit": null, - "basis_source": null, - "resource_shares": {}, - "partial_ratio": false, - "expression_cells": [], - "crew_table": false, - "spaced_names": [], - "formula_rows": [], - "special_glyphs": [], - "capacity_formula_here": false, - "variant_key": [ - "설 치", - "철 거" - ], - "condition_note": [ - "구 분", - "단 위", - "수 량", - "시 공 량(m)" - ], - "raw_row": [ - [ - "설 치", - "철 거", - "", - "", - "" - ], - [ - "비 계 공", - "인/일", - "2", - "220", - "330" - ] - ] } ], - "variant_keys": [ - "설 치", - "철 거" - ] + "variant_keys": [] }, { "work_item_code": "FP-12-03", @@ -35406,11 +33418,308 @@ "" ] ] + }, + { + "pum_table_id": "F0356", + "section": "12-17-1. 펌프카 타설", + "source_line": 6520, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [], + "condition_note": [ + "슬 럼 프", + "기준 시공량" + ], + "raw_row": [ + [ + "무근콘크리트", + "철근콘크리트", + "" + ], + [ + "8 ~ 12 cm", + "130", + "125" + ], + [ + "15 cm", + "135", + "130" + ], + [ + "18 cm 이상", + "145", + "140" + ] + ] + }, + { + "pum_table_id": "F0357", + "section": "12-17-1. 펌프카 타설", + "source_line": 6534, + "pum_form": "coefficient", + "form_basis": "행 키가 기호뿐 ['f₁']", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [], + "condition_note": [ + "유 형", + "Type-Ⅰ", + "Type-Ⅱ", + "Type-Ⅲ", + "Type-Ⅳ" + ], + "raw_row": [ + [ + "f₁", + "1.4", + "1.0", + "0.8", + "0.3" + ] + ] + }, + { + "pum_table_id": "F0358", + "section": "12-17-1. 펌프카 타설", + "source_line": 6538, + "pum_form": "reference", + "form_basis": "사람 판정 — 시설유형 Type-Ⅰ~Ⅳ 적용 기준 설명", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [], + "condition_note": [ + "구 분", + "적용 기준" + ], + "raw_row": [ + [ + "Type-Ⅰ", + "매트기초 등 펌프차 작업에 제약이 없는 시설물" + ], + [ + "Type-Ⅱ", + "벽, 기둥, 보, 슬래브. 교대, 교각 등 펌프차 작업에 큰 지장이 없어 일반적인 시공이 가능한 시설물" + ], + [ + "Type-Ⅲ", + "옹벽, 줄기초, 슬래브 없는[월거더 : wall girder] 구조의 기둥과 보 등 펌프차 작업에 제약을 받는 타설 부위가 좁거나 깊은 시설물" + ], + [ + "Type-Ⅳ", + "절·성토부 비탈면에 시공되는 구조물 등 펌프차 작업에 제약이 매우 큰 시설물" + ] + ] + }, + { + "pum_table_id": "F0359", + "section": "12-17-1. 펌프카 타설", + "source_line": 6547, + "pum_form": "coefficient", + "form_basis": "행 키가 기호뿐 ['f₁']", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [], + "condition_note": [ + "유 형", + "Type-Ⅰ", + "Type-Ⅱ", + "Type-Ⅲ" + ], + "raw_row": [ + [ + "f₁", + "1.2", + "1.0", + "0.8" + ] + ] + }, + { + "pum_table_id": "F0360", + "section": "12-17-1. 펌프카 타설", + "source_line": 6551, + "pum_form": "reference", + "form_basis": "사람 판정 — 현장조건 Type-Ⅰ~Ⅲ 적용 기준 설명", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [], + "condition_note": [ + "구 분", + "적용 기준" + ], + "raw_row": [ + [ + "Type-Ⅰ", + "대기 공간이 충분히 넓어 믹서트럭 2대가 병렬로 타설 준비가 가능하며 지속적인 타설을 수행하는 경우" + ], + [ + "Type-Ⅱ", + "믹서트럭이 1대씩 직렬로 대기하며 순차적으로 타설 준비하여 타설하는 일반적인 경우" + ], + [ + "Type-Ⅲ", + "믹서트럭의 대기 공간이 매우 협소하고 진출입 길이가 길어 연속적인 타설이 어려운 경우" + ] + ] + }, + { + "pum_table_id": "F0361", + "section": "12-17-1. 펌프카 타설", + "source_line": 6560, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [], + "condition_note": [ + "배 합 비", + "재 료", + "손 비 비 기" + ], + "raw_row": [ + [ + "시멘트(kg)", + "모래(㎥)", + "자갈(㎥)", + "콘크리트공(인)", + "보통인부(인)", + "" + ], + [ + "1:2:4", + "320", + "0.45", + "0.90", + "0.9", + "1.0" + ], + [ + "1:3:6", + "220", + "0.47", + "0.94", + "0.9", + "0.9" + ], + [ + "1:4:8", + "170", + "0.48", + "0.96", + "0.9", + "0.7" + ] + ] + }, + { + "pum_table_id": "F0362", + "section": "12-17-1. 펌프카 타설", + "source_line": 6569, + "pum_form": "requirement", + "form_basis": "값 단위 '(m)'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "설 치", + "철 거" + ], + "condition_note": [ + "구 분", + "단 위", + "수 량", + "시 공 량(m)" + ], + "raw_row": [ + [ + "설 치", + "철 거", + "", + "", + "" + ], + [ + "비 계 공", + "인/일", + "2", + "220", + "330" + ] + ] } ], "variant_keys": [ "무근콘크리트", - "철근콘크리트" + "철근콘크리트", + "설 치", + "철 거" ] }, { @@ -41930,6 +40239,1822 @@ "" ] ] + }, + { + "pum_table_id": "F0452", + "section": "14-2. 근주이식", + "source_line": 7823, + "pum_form": "reference", + "form_basis": "헤더 '할인'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [], + "condition_note": [ + "번호", + "할인․할증 요소", + "할인․할증 반영이 필요한 공종" + ], + "raw_row": [ + [ + "사업 구분", + "세 부 공 종", + "", + "", + "", + "" + ], + [ + "1-4-1", + "작업시기", + "숲가꾸기", + "6-4-1 덩굴걷기", + "6-4-2 덩굴약제 살포처리", + "6-4-3 소금처리" + ], + [ + "6-4-4 뿌리제거", + "6-5 어린나무가꾸기", + "", + "", + "", + "" + ], + [ + "1-4-2", + "조림 후 경과연수", + "풀베기", + "6-2-2 풀베기(줄베기)", + "", + "" + ], + [ + "1-4-3", + "집단화정도", + "숲가꾸기", + "3-1 경계표시", + "4-2-1 단목베기", + "6-2-1 풀베기(둘레베기)" + ], + [ + "6-2-2 풀베기(줄베기)", + "6-2-3 풀베기(모두베기)", + "6-3 맹아제거", + "", + "", + "" + ], + [ + "6-4-1 덩굴걷기", + "6-4-2 덩굴약제 살포처리", + "6-4-3 소금처리", + "", + "", + "" + ], + [ + "6-4-4 뿌리제거", + "6-5 어린나무가꾸기", + "6-6 가지치기 및 수형교정", + "", + "", + "" + ], + [ + "1-4-4", + "작업구역", + "병해충방제", + "8-6-2 드론방제", + "", + "" + ], + [ + "1-4-5", + "경사도 (산지경사)", + "조림", + "3-5 예정지정리작업", + "5-3-1 나무식재", + "5-4 파종조림" + ], + [ + "5-5천연하종갱신", + "5-6 움싹갱신", + "5-7 생태보완조림", + "", + "", + "" + ], + [ + "6-1 비료주기", + "", + "", + "", + "", + "" + ], + [ + "숲가꾸기", + "3-1 경계표시", + "3-3 작업로 설치", + "3-6 산물 임내정리", + "", + "" + ], + [ + "4-2-1 단목베기", + "6-2-1 풀베기(둘레베기)", + "6-2-2 풀베기(줄베기)", + "", + "", + "" + ], + [ + "6-2-3 풀베기(모두베기)", + "6-3 (맹아제거)", + "6-4-1 덩굴걷기", + "", + "", + "" + ], + [ + "6-4-2 덩굴약제 살포처리", + "6-4-3 소금처리", + "6-4-4 뿌리제거", + "", + "", + "" + ], + [ + "6-5 어린나무가꾸기", + "6-6 가지치기 및 수형교정", + "7-1 인력집재", + "", + "", + "" + ], + [ + "7-2 수라집재", + "7-3 아키야윈치 (임업용 윈치)", + "7-4-2 소형가선집재 (2드럼윈치)", + "", + "", + "" + ], + [ + "7-5-2 트랙터부착형집재(지면끌기)", + "7-6 스윙야더 집재", + "7-7-2 HAM200 집재(소형가선)", + "", + "", + "" + ], + [ + "7-9-2 부탁형 타워 (K-301, HAM300)", + "7-15-2 소형 포워더 운재", + "", + "", + "", + "" + ], + [ + "국유임산물 (수확)", + "4-1-1 수확베기 (임업용 동력기계톱)", + "7-4-1 소형가선집재 (2드럼윈치)", + "7-5-1 트랙터부착형집재기", + "", + "" + ], + [ + "7-6 스윙야더 집재", + "7-7-1 HAM200 (춘천․스마트)", + "7-8-3 타워야더 (RME-300T)", + "", + "", + "" + ], + [ + "7-9-1 부탁형 타워 (K-301, HAM300)", + "7-11 동력상하차기 (우드그래플)집재", + "", + "", + "", + "" + ], + [ + "산림병해충방제", + "3-3 작업로 설치", + "4-2-1 단목베기", + "3-6 산물 임내정리", + "", + "" + ], + [ + "7-1-2 인력집재", + "3-7 재해산물수집", + "7-2 수라집재", + "", + "", + "" + ], + [ + "7-3 아키야윈치 (임업용 윈치)", + "7-4-2 소형가선집재 (2드럼윈치)", + "7-5-2 트랙터부착형집재(지면끌기)", + "", + "", + "" + ], + [ + "7-7-2 HAM200 집재(소형가선)", + "7-8-3 타워야더 (RME-300T)", + "7-10 굴착기 우드그랩 산지집재", + "", + "", + "" + ], + [ + "8-1-1 나무주사", + "8-4 끈끈이 롤 트랩", + "", + "", + "", + "" + ], + [ + "소나무재선충병 방제", + "3-3 작업로 설치", + "4-2-1 단목베기", + "7-3 아키야윈치 (임업용 윈치)", + "", + "" + ], + [ + "7-4-2 소형가선집재 (2드럼윈치)", + "7-5-2 트랙터부착형집재(지면끌기)", + "7-7-2 HAM200 집재(소형가선)", + "", + "", + "" + ], + [ + "7-9-2 부착형 타워 (K-301, HAM300)", + "8-1-1 나무주사", + "8-6-2 드론방제", + "", + "", + "" + ], + [ + "1-4-6", + "작업장까지의 이동거리", + "조림", + "3-5 예정지정리작업", + "5-3-1 나무식재", + "5-4 파종조림" + ], + [ + "5-5 천연하종갱신", + "5-6 움싹갱신", + "5-7 생태보완조림", + "", + "", + "" + ], + [ + "6-1 비료주기", + "", + "", + "", + "", + "" + ], + [ + "1-4-7", + "하층식생", + "숲가꾸기", + "6-4-3 소금처리", + "6-4-4 뿌리제거", + "" + ], + [ + "1-4-8", + "장애물의 정도", + "숲가꾸기", + "3-2 작업로 선정", + "4-2-1 단목베기", + "6-6 가지치기 및 수형교정" + ], + [ + "7-1-2 인력집재", + "7-3 아키야윈치 (임업용 윈치)", + "7-4-2 소형가선집재 (2드럼윈치)", + "", + "", + "" + ], + [ + "국유임산물 (수확)", + "4-1-1 수확베기 (임업용 동력기계톱)", + "", + "", + "", + "" + ], + [ + "산림병해충방제", + "4-2-1 단목베기", + "3-7 재해산물수집", + "7-1-2 인력집재", + "", + "" + ], + [ + "7-3 아키야윈치 (임업용 윈치)", + "7-4-2 소형가선집재 (2드럼윈치)", + "8-1-1 나무주사", + "", + "", + "" + ], + [ + "소나무재선충병 방제", + "3-7 재해산물수집", + "4-2-1 단목베기", + "7-1-2 인력집재", + "", + "" + ], + [ + "7-3 아키야윈치 (임업용 윈치)", + "7-4-2 소형가선집재 (2드럼윈치)", + "8-3 소각, 매몰, 훈증, 박피", + "", + "", + "" + ], + [ + "1-4-9", + "제거대상 식생", + "숲가꾸기", + "3-3 작업로 설치", + "", + "" + ], + [ + "산림병해충방제", + "3-3 작업로 설치", + "", + "", + "", + "" + ], + [ + "소나무재선충병 방제", + "3-3 작업로 설치", + "", + "", + "", + "" + ], + [ + "1-4-10", + "토양상태 (토양조건)", + "조림", + "5-3-1 나무식재", + "5-4 파종조림", + "5-5 천연하종갱신" + ], + [ + "5-7 생태보완조림", + "6-1 비료주기", + "", + "", + "", + "" + ], + [ + "숲가꾸기", + "6-4-4 뿌리제거", + "", + "", + "", + "" + ], + [ + "1-4-11", + "덩굴피복도", + "숲가꾸기", + "6-4-1 덩굴걷기", + "", + "" + ], + [ + "1-4-12", + "제거대상 피복도", + "어린나무가꾸기", + "6-5 어린나무가꾸기", + "", + "" + ], + [ + "1-4-13", + "주행 장애물 상태", + "숲가꾸기", + "7-15-2 초소형포워더운재", + "", + "" + ], + [ + "국유임산물 (수확)", + "7-15-1 초소형포워더운재", + "7-16 소형포워더 운재", + "7-17 소형트럭 운재", + "", + "" + ], + [ + "7-18 지조운반 (포워더 활용)", + "", + "", + "", + "", + "" + ], + [ + "산림병해충방제", + "7-10 굴착기 우드그랩 산지집재", + "7-15-2 초소형포워더운재", + "", + "", + "" + ], + [ + "소나무재선충병 방제", + "7-15-2 초소형포워더운재", + "", + "", + "", + "" + ], + [ + "1-4-14", + "집재방향", + "숲가꾸기", + "7-5-2 트랙터부착형집재(지면끌기)", + "7-7-2 HAM200 집재(소형가선)", + "" + ], + [ + "국유임산물 (수확)", + "7-4-1 소형가선집재 (2드럼윈치)", + "7-5-1 트랙터부착형집재(지면끌기)", + "7-6 스윙야더 집재", + "", + "" + ], + [ + "7-7-1 HAM200 집재(소형가선)", + "7-8-3 타워야더 (RME-300T)", + "7-9-1 부착형 타워 (K-301, HAM300)", + "", + "", + "" + ], + [ + "산림병해충방제", + "7-5-2 트랙터부착형집재(지면끌기)", + "7-7-2 HAM200 집재(소형가선)", + "7-8-3 타워야더 (RME-300T)", + "", + "" + ], + [ + "소나무재선충병 방제", + "3-7 재해산물수집", + "7-1-2 인력집재", + "7-5-2 트랙터부착형집재(지면끌기)", + "", + "" + ], + [ + "7-7-2 HAM200 집재(소형가선)", + "7-9-2 부착형 타워 (K-301, HAM300)", + "8-8-1 훈증더미제거 (인력)", + "", + "", + "" + ], + [ + "1-4-15", + "횡단운반거리 (측방집재거리)", + "숲가꾸기", + "7-2 수라집재", + "7-7-2 HAM200 집재(소형가선)", + "7-9-2 부착형 타워 (K-301, HAM300)" + ], + [ + "국유임산물 (수확)", + "7-4-1 소형가선집재 (2드럼윈치)", + "7-6 스윙야더 집재", + "7-7-1 HAM200 집재(소형가선)", + "", + "" + ], + [ + "7-8-3 타워야더 (RME-300T)", + "7-9-1 부착형 타워 (K-301, HAM300)", + "", + "", + "", + "" + ], + [ + "산림병해충방제", + "7-7-2 HAM200 집재(소형가선)", + "7-8-3 타워야더 (RME-300T)", + "", + "", + "" + ], + [ + "소나무재선충병 방제", + "7-2 수라집재", + "7-7-2 HAM200 집재(소형가선)", + "7-9-2 부착형 타워 (K-301, HAM300)", + "", + "" + ], + [ + "1-4-16", + "규격재 생산", + "숲가꾸기", + "4-2-1 단목베기", + "", + "" + ], + [ + "1-4-17", + "방제대상목 분포", + "산림병해충방제", + "4-2-1 단목베기", + "8-1-1 나무주사", + "8-4 끈끈이 롤 트랩" + ], + [ + "소나무재선충병 방제", + "8-3 소각, 매몰, 훈증, 박피", + "", + "", + "", + "" + ], + [ + "1-4-18", + "방제대상목 평균 경급", + "산림병해충방제", + "7-3 아키야윈치 (임업용 윈치)", + "7-4-2 소형가선집재 (2드럼윈치)", + "" + ], + [ + "소나무재선충병 방제", + "7-3 아키야윈치 (임업용 윈치)", + "7-4-2 소형가선집재 (2드럼윈치)", + "8-10 그물망 피복", + "", + "" + ], + [ + "1-4-19", + "매개충 나무주사", + "소나무재선충병 방제", + "8-1-1 나무주사", + "", + "" + ], + [ + "1-4-20", + "방제지 사면", + "소나무재선충병 방제", + "8-6-2 드론방제", + "", + "" + ], + [ + "1-4-21", + "방제지 접근성", + "소나무재선충병 방제", + "8-6-2 드론방제", + "", + "" + ], + [ + "1-4-22", + "방제 수종", + "소나무재선충병 방제", + "8-3 소각, 매몰, 훈증, 박피", + "", + "" + ], + [ + "1-4-23", + "방제목 운반거리", + "소나무재선충병 방제", + "8-10 그물망 피복", + "", + "" + ], + [ + "1-4-24", + "방제장비 규격", + "소나무재선충병 방제", + "8-8-2 훈증더미제거 (기계)", + "", + "" + ], + [ + "1-4-25", + "작업시간 제한", + "모든 사업", + "모든 공종", + "", + "" + ], + [ + "1-4-26", + "소규모 작업물량 제한", + "", + "", + "", + "" + ] + ] + }, + { + "pum_table_id": "F0453", + "section": "14-2. 근주이식", + "source_line": 7907, + "pum_form": "reference", + "form_basis": "사람 판정 — 〔부록 2〕 산림사업별 적용 가능 공종 목록 — 절 경계가 밀려 붙음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [], + "condition_note": [ + "사업종", + "구분", + "적용 가능 공종" + ], + "raw_row": [ + [ + "공종", + "", + "", + "", + "", + "" + ], + [ + "조림", + "시행 지침", + "3-5 예정지 정리작업", + "5-3-1 나무식재", + "5-30 표시봉설치", + "5-31 지주목설치" + ], + [ + "5-32 대절작업", + "5-33 묘목 가식작업", + "5-34 묘목 소운반", + "5-4 파종조림", + "", + "" + ], + [ + "5-5 천연하종갱신", + "5-6 움싹갱신", + "5-7 생태보완조림", + "5-8 큰나무 공익조림", + "", + "" + ], + [ + "6-1 비료주기", + "", + "", + "", + "", + "" + ], + [ + "지침 외", + "5-1-1 관목굴취", + "5-1-2 교목굴취(나무높이)", + "5-1-3 교목굴취(근원직경)", + "5-2 뿌리돌림", + "" + ], + [ + "5-3-2 관목식재(단식)", + "5-3-3 관목식재(군식)", + "5-3-4 교목식재 (나무높이)", + "5-3-5 교목식재 (흉고직경)", + "", + "" + ], + [ + "10-13 드론운반공", + "", + "", + "", + "", + "" + ], + [ + "숲가 꾸기", + "시행 지침", + "3-1 경계표시", + "3-2 작업로 선정", + "3-3 작업로 설치", + "6-2-1 둘러베기 (풀베기)" + ], + [ + "6-2-2 줄베기 (풀베기)", + "6-2-3 모두베기 (풀베기)", + "6-3 맹아제거", + "6-4-1 덩굴걷기", + "", + "" + ], + [ + "6-4-2 덩굴 약제 살포처리", + "6-4-3 소금처리", + "6-4-4 뿌리제거", + "6-5어린나무 가꾸기", + "", + "" + ], + [ + "4-2-1 단목베기", + "4-3 위험목 베기", + "6-6 가지치기 및 수형교정", + "7-1-2 인력집재", + "", + "" + ], + [ + "7-3아키야윈치 (임업용 윈치)", + "7-4-2 소형가선집재 (2드럼윈치)", + "7-2 수라집재", + "7-5-2 트랙터부착형집재(지면끌기)", + "", + "" + ], + [ + "7-15-2 초소형포워더 운재", + "7-7-2 HAM200 집재(소형가선)", + "7-8-1 가선설치 (중형가선)", + "7-8-2 가선해체 (중형가선)", + "", + "" + ], + [ + "7-9-2 부착형타워 K-301, HAM300 (중형가선)", + "7-12 동력상하차기 (우드그래플) 집적", + "3-6 산물 임내정리", + "", + "", + "" + ], + [ + "지침 외", + "3-5 드론 영상 촬영", + "4-5 벌도 위험목 점검", + "4-6 벌목부 작업안전 보조", + "7-13 검척", + "" + ], + [ + "국유 임산물 수확", + "시행 지침", + "4-1-1 수확베기", + "4-1-2 하베스터 베기", + "7-3아키야윈치 (임업용 윈치)", + "7-5-1 트랙터부착형집재(지면끌기)" + ], + [ + "7-4-1 소형가선집재 (2드럼윈치)", + "7-6 스윙야더 집재", + "7-8-1 가선설치 (중형가선)", + "7-8-2 가선해체 (중형가선)", + "", + "" + ], + [ + "7-8-3 타워야더 RME-300T", + "7-7-1 HAM200 집재(소형가선)", + "7-9-1 부착형 타워 (K-301, HAM300)", + "7-11 동력상하차기 (우드그래플) 집재", + "", + "" + ], + [ + "7-1-1 인력집재 (수확)", + "7-15-1 초소형포워더 운재", + "7-16 소형포워더 운재", + "7-17 소형트럭 운재", + "", + "" + ], + [ + "7-18 지조운반 (포워더 활용)", + "7-12 동력상하차기 (우드그래플) 집적", + "7-13 검척", + "7-14 원목 운반 (수확)", + "", + "" + ], + [ + "3-4-2 임산물 운반로 및 작업로 신설", + "3-4-3 임산물 운반로 및 작업로 보수", + "", + "", + "", + "" + ], + [ + "지침 외", + "4-5 벌도 위험목 점검", + "4-6 벌목부 작업안전 보조", + "", + "", + "" + ], + [ + "산림 병해충 방제", + "시행 지침", + "3-3 작업로 설치", + "4-2-1 단목베기", + "3-6 산물 임내정리", + "8-1-1 나무주사" + ], + [ + "8-1-2 약제주입병", + "8-2-2 약제주입기준 (솔잎혹파리)", + "8-2-3 약제주입기준 (솔껍질깍지벌레)", + "8-2-4 약제주입기준 (솔나방)", + "", + "" + ], + [ + "8-2-5 약제주입기준 (푸사리움가지마름병)", + "8-3 소각, 매몰, 훈증, 박피", + "8-11 이동식 임목파쇄", + "8-4 끈끈이 롤 트랩", + "", + "" + ], + [ + "7-1-2 인력집재", + "3-4 재해산물 수집", + "7-3아키야윈치 (임업용 윈치)", + "7-4-2 소형가선집재 (2드럼윈치)", + "", + "" + ], + [ + "7-2 수라집재", + "7-5-2 트랙터부착형집재(지면끌기)", + "7-16 소형포워더 운재", + "7-7-2 HAM200 집재(소형가선)", + "", + "" + ], + [ + "7-8-1 가선설치 (중형가선)", + "7-8-2 가선해체 (중형가선)", + "7-8-3 타워야더 RME-300T", + "7-12 동력상하차기 (우드그래플)집적", + "", + "" + ], + [ + "7-10 굴착기 우드그랩 산지집재", + "4-3 위험목 베기", + "8-6-1 유인헬기방제", + "8-6-3 지상방제", + "", + "" + ], + [ + "지침 외", + "3-5 드론 영상 촬영", + "4-5 벌도 위험목점검", + "4-6 벌목부 작업안전 보조", + "7-13 검척", + "" + ], + [ + "소나무 재선충병 방제", + "시행 지침", + "3-3 작업로 설치", + "8-1-1 나무주사", + "8-1-2 약제주입병", + "8-2-1 약제주입기준 (소나무재선충병)" + ], + [ + "8-6-2 드론방제", + "8-6-3 지상방제", + "8-5 페르몬 유인트랩 (매개충 유인트랩)", + "4-1 수확베기 (모두베기)", + "", + "" + ], + [ + "8-3 소각, 매몰, 훈증, 박피", + "4-2-1 단목베기 (강도간벌)", + "8-9 잔가지 줍기", + "8-11 이동식 임목 파쇄", + "", + "" + ], + [ + "8-10 그물망 피복", + "8-8-1 훈증더미 제거 (인력)", + "8-8-2 훈증더미 제거 (기계)", + "4-3 위험목 베기", + "", + "" + ], + [ + "8-7 방제 실행등록", + "7-1-2 인력집재", + "7-3아키야윈치 (임업용 윈치)", + "7-4-2 소형가선집재 (2드럼윈치)", + "", + "" + ], + [ + "7-2 수라집재", + "7-5-2 트랙터부착형집재(지면끌기)", + "7-16 소형포워더 운재", + "7-7-2 HAM200 집재(소형가선)", + "", + "" + ], + [ + "7-8-1 가선설치 (중형가선)", + "7-8-2 가선해체 (중형가선)", + "7-8-3 타워야더 RME-300T", + "7-9-2 부착형 타워 (K-301, HAM300)", + "", + "" + ], + [ + "7-12 동력상하차기 (우드그래플)집적", + "7-10 굴착기 우드그랩 산지집재", + "", + "", + "", + "" + ], + [ + "지침 외", + "3-5 드론 영상 촬영", + "4-4 가지정리", + "4-5 벌도 위험목점검", + "4-6 벌목부 작업안전 보조", + "" + ], + [ + "임도, 사방,복원", + "품셈", + "4-2-2 단목베기 (1,000㎡)", + "5-1-4 떼채취", + "5-9 해안조림", + "5-10 사방조림" + ], + [ + "5-11 사초심기", + "5-12 떼붙임(재배잔디)", + "5-13 떼심기", + "5-14 새심기", + "", + "" + ], + [ + "5-15 바자얽기", + "5-16 선떼붙이기공", + "5-17 조공", + "5-18 씨뿌리기(줄)", + "", + "" + ], + [ + "5-19 표토 절취 및 정지", + "5-20 표토관리", + "5-21 표토이식", + "5-22 평떼", + "", + "" + ], + [ + "5-23 줄떼", + "5-24 씨앗뿜어붙이기", + "5-25 거적덮기", + "5-26 흙갈이", + "", + "" + ], + [ + "5-27 식재면 관리", + "5-28 비탈덮기", + "5-29 복사이식", + "9-1 굴착", + "", + "" + ], + [ + "9-2 노선 굴진보조원", + "9-3 토사깍기", + "9-4 암절취", + "9-5 발파암", + "", + "" + ], + [ + "9-6 발파암 소할", + "9-7 무근콘크리트 깨기", + "9-8 철근콘크리트깨기", + "9-9 석축헐기", + "", + "" + ], + [ + "9-10 기존포장 깨기", + "9-11 포장절단", + "9-12 측구터파기", + "9-13 구조물터파기", + "", + "" + ], + [ + "9-14 되메우기 및 다짐", + "9-15 표토제거", + "9-16 노체", + "9-17 다짐", + "", + "" + ], + [ + "9-18 층따기", + "9-19 면고르기", + "9-20 뿌리다듬기 및 적재", + "9-21 제근", + "", + "" + ], + [ + "9-22 섞기", + "10-1 시멘트운반", + "10-2 철근운반", + "10-3 골재운반", + "", + "" + ], + [ + "10-4 중기운반", + "10-5 레미콘 운반비 산정", + "10-6 인력운반", + "10-7 모노레일 운반", + "", + "" + ], + [ + "10-8 케이블 크레인 운반", + "10-9 덤프트럭 운반", + "10-10 헬리콥터 자재 운반", + "10-11 불도저 운반", + "", + "" + ], + [ + "10-12 덤프 운반", + "11-1 콘테이너형 가설건축물", + "11-2 토공의 비탈 규준틀", + "11-3 수평규준틀", + "", + "" + ], + [ + "11-4 쇄석․혼합석 부설", + "12-1 콘크리트타설", + "12-2 표면 마무리", + "12-3 철근 현장가공 및 조림", + "", + "" + ], + [ + "12-4 합판거푸집", + "12-5 문양거푸집", + "12-6 콘크리트 포장 (인력시공)", + "12-7 포장절단 및 줄눈설치", + "", + "" + ], + [ + "12-8 콘크리트 포장 거푸집", + "12-9 측구", + "12-10 맹암거", + "12-11 관부설", + "", + "" + ], + [ + "12-12 날개벽", + "12-13 면벽", + "12-14 가배수관", + "12-15 집수정", + "", + "" + ], + [ + "12-16 맨홀", + "12-17 펌프카", + "12-18 철근가공조립 (복잡)", + "12-19 강관비계", + "", + "" + ], + [ + "12-20 강관동바리", + "12-21 아스팔트코팅(2회)", + "12-22 P.V.C 파이프 설치(50㎜)", + "12-23 부직포설치", + "", + "" + ], + [ + "12-24 뒷채움 및 되메우기", + "12-25 기초잡석", + "12-26 물푸기", + "12-27 지수판", + "", + "" + ], + [ + "12-28 신구 BOX접합", + "12-29 스페이셔 설치 (몰탈 블룩)", + "12-30 다웰바 설치", + "12-31 물끊기 홈 (NOTCH) 설치", + "", + "" + ], + [ + "12-32 전선관 설치 (P.V.C ∅54m/m)", + "12-33 비닐깔기", + "12-34 암거이음받침", + "12-35 콘크리트 표면 강화재(하드너)", + "", + "" + ], + [ + "12-36 P.C BOX 설치", + "12-37 콘크리트 타설 (무근진동기 제외)", + "12-38 유로폼", + "13-1 석재 및 골재", + "", + "" + ], + [ + "13-2 채집 및 세척", + "13-3 기초다짐 및 뒷채움", + "13-4 돌쌓기", + "13-5 돌붙임", + "", + "" + ], + [ + "13-6 큰돌쌓기", + "13-7 큰돌붙이기", + "13-8 막돌쌓기", + "13-9 서식지 조성 돌쌓기 및 놓기", + "", + "" + ], + [ + "13-10 말뚝박기공", + "13-11 돌망태", + "13-12 비탈다듬기", + "13-13 흙막이", + "", + "" + ], + [ + "13-14 식생토낭 및 포트", + "13-15 목재공", + "13-16 매트부설", + "14-1 입목뿌리 밑막이", + "", + "" + ], + [ + "14-2 근주이식", + "", + "", + "", + "", + "" + ] + ] + }, + { + "pum_table_id": "F0454", + "section": "14-2. 근주이식", + "source_line": 7983, + "pum_form": "reference", + "form_basis": "'단가산출서' — 채워 넣으라고 둔 빈 서식", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": true, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [], + "condition_note": [ + "ha당 조림 단가산출서(예시)" + ], + "raw_row": [ + [ + "위치 및 면적", + "1-0-1-0 또는 00임반 00소반", + "비 고", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "구분", + "작업량", + "단위품 (인원,수량,요율)", + "소요품", + "단가(원)", + "할인 ㆍ 증률", + "계(원)", + "", + "", + "", + "", + "" + ], + [ + "", + "단위작업", + "", + "단위", + "", + "단위", + "", + "종류", + "", + "", + "", + "" + ], + [ + "단 위 작 업 별", + "1. 예정지정리", + "", + "", + "", + "", + "", + "", + "", + "", + "1,619,278", + "" + ], + [ + "- 직접노무비", + "", + "", + "", + "", + "", + "", + "", + "", + "1,506,543", + "", + "" + ], + [ + "ㆍ벌채부산물 임내정리 (벌채와 동시 정리지역)", + "1.0", + "ha", + "7.00", + "인", + "7.00", + "195,655", + "특별50% 보통50%", + "10%", + "1,506,543", + "2인 1조", + "" + ], + [ + "", + "", + "", + "", + "", + "(221,506)", + "(특별인부)", + "", + "", + "", + "", + "" + ], + [ + "", + "", + "", + "", + "", + "(169,804)", + "(보통인부)", + "", + "", + "", + "", + "" + ], + [ + "- 재료비(체인톱)", + "", + "", + "", + "", + "", + "", + "", + "", + "86,275", + "", + "" + ], + [ + "ㆍ보통휘발유(주연료)", + "3.5", + "대/ha", + "5.60", + "ℓ/대", + "19.60", + "1,537", + "무연(ℓ)", + "", + "30,125", + "", + "" + ], + [ + "ㆍ보통휘발유(잡품)", + "30,125", + "원", + "40", + "%", + "", + "", + "", + "", + "12,050", + "", + "" + ], + [ + "ㆍ체인오일(친환경)", + "3.5", + "대/ha", + "2.10", + "ℓ/대", + "7.35", + "6,000", + "국내산(ℓ)", + "", + "44,100", + "", + "" + ], + [ + "- 기계경비(체인톱)", + "3.5", + "대", + "0.0084", + "", + "", + "900,000", + "체인톱(대)", + "", + "26,460", + "", + "" + ], + [ + "2. 식 재", + "", + "", + "", + "", + "", + "", + "", + "", + "3,379,002", + "", + "" + ], + [ + "- 직접노무비", + "", + "", + "", + "", + "", + "", + "", + "", + "3,006,510", + "", + "" + ], + [ + "ㆍ식재작업", + "3,000", + "본/ha", + "4.00", + "인/천본", + "12.00", + "185,315", + "특별30% 보통70%", + "10%", + "2,446,158", + "소나무 2-0용(중묘)", + "" + ], + [ + "", + "", + "", + "", + "", + "", + "(221,506)", + "(특별인부)", + "", + "", + "", + "" + ], + [ + "", + "", + "", + "", + "", + "", + "(169,804)", + "(보통인부)", + "", + "", + "", + "" + ], + [ + "ㆍ표시봉설치", + "3,000", + "본/ha", + "0.70", + "인/천본", + "2.10", + "169,804", + "보통인부", + "", + "356,588", + "", + "" + ], + [ + "ㆍ소운반", + "3,000", + "본/ha", + "0.40", + "인/천본", + "1.20", + "169,804", + "보통인부", + "", + "203,764", + "", + "" + ], + [ + "- 재료비", + "", + "", + "", + "", + "", + "", + "", + "", + "360,000", + "", + "" + ], + [ + "ㆍ묘목", + "3,000", + "본/ha", + "", + "", + "", + "0원", + "원/본", + "", + "0", + "관급자재", + "" + ], + [ + "ㆍ표시봉", + "3,000", + "개/ha", + "", + "", + "", + "120", + "원/개", + "", + "360,000", + "대나무", + "" + ], + [ + "- 경 비", + "", + "", + "", + "", + "", + "", + "", + "", + "12,492", + "", + "" + ], + [ + "ㆍ대운반", + "3,000", + "본/ha", + "", + "", + "", + "4,164", + "원/1,000본", + "", + "12,492", + "운반 50km", + "" + ], + [ + "합 계", + "순 원 가", + "", + "", + "", + "", + "", + "", + "", + "", + "4,998,280", + "" + ], + [ + "직접노무비", + "", + "", + "", + "", + "", + "", + "", + "", + "4,513,053", + "", + "" + ], + [ + "재 료 비", + "", + "", + "", + "", + "", + "", + "", + "", + "446,275", + "", + "" + ], + [ + "경비(기계경비)", + "", + "", + "", + "", + "", + "", + "", + "", + "38,952", + "", + "" + ], + [ + "<할인․할증률 적용>", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "구 분", + "할인․할증요소", + "단위작업(%)", + "비고", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "예정지정리", + "식재", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "o 벌채구역 크기(1-4-4)", + "개벌지 크기가 3,000㎡ 이상", + "0%", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "o 경사도(1-4-5)", + "중경사(15∼30°)", + "5%", + "5%", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "o 작업장까지 이동거리(1-4-6)", + "1.3㎞ 미만", + "0%", + "0%", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "o 토양상태(1-4-10)", + "돌함량 10∼30%", + "", + "5%", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "o 미이용산림 바이오 메스 정리량", + "50㎥∼100㎥/ha미만", + "5%", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "o 산불피해지", + "", + "0%", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "합 계", + "", + "10%", + "10%", + "", + "", + "", + "", + "", + "", + "", + "" + ] + ] } ], "variant_keys": [] diff --git a/resources/knowledge/original/경쟁사 프로그램/STmate 분석/20_분석/27_계약_실행_기성_단계.md b/resources/knowledge/original/경쟁사 프로그램/STmate 분석/20_분석/27_계약_실행_기성_단계.md index f0d07fa8..110b59a6 100644 --- a/resources/knowledge/original/경쟁사 프로그램/STmate 분석/20_분석/27_계약_실행_기성_단계.md +++ b/resources/knowledge/original/경쟁사 프로그램/STmate 분석/20_분석/27_계약_실행_기성_단계.md @@ -91,7 +91,7 @@ rGrd_Jab : 공종 | 명칭 | 규격 | 도급액 | 전회금액 | 전율 | 금 `BOQ11`에 **`QTY1_B` · `QTY1_N` · `QTY1_O` · `QTY1_H`** 네 칸이 있다(02번). 설계 단계 표본에서는 **전부 0**이다. -⇒ **기성 수량 칸**으로 보인다(전회/금회/기타). 기성 STC 표본이 없어 각 글자의 뜻은 **B등급**이다. +⇒ 네 이름은 DB 필드명으로만 확인된다. 대응 라벨·설명과 기성 STC 표본이 없으므로 **기성 수량 칸인지와 각 글자의 뜻 모두 미확인**이다. ## 4. 실행예산 — `wM_Boq_ExecX` (중기 실행단가) @@ -112,7 +112,7 @@ rGrd_Jab : 공종 | 명칭 | 규격 | 도급액 | 전회금액 | 전율 | 금 ## 5. 준공 -`준공조서(을/병)`(0646·0647), 열은 **`계약금액 | 준공금액`** 둘뿐이다. 별도 계산 없이 최종 누계를 옮기는 것으로 보인다. +`준공조서(을/병)`(0646·0647), 열은 **`계약금액 | 준공금액`** 둘뿐이다. 별도 계산 여부와 최종 누계 이관 규칙은 계산식 원문·표본이 없어 **미확인**이다. ## 6. 보조 — `wM_GetAMT` (잡비 금액 불러오기) @@ -148,7 +148,7 @@ RDbGrid_Boq : 공종 | 명칭 | 규격 | 수량 | 단위 | 계금액 | 비고 | 기성 누적 | 몰랐음 | ✅ **전회·금회·누계 + 각 비율**, 사정·잔량 | | 부가세 축 | 단계 무관으로 봄 | ⚠ **단계마다 선택지가 다름** | | 실행예산 | 서식 이름만 | ✅ **중기 실행단가 + 차액 보정 순서** | -| `QTY1_B/N/O/H` | 「특수 수량」 | ◐ **기성 수량 칸으로 추정** (B등급, 표본 없음) | +| `QTY1_B/N/O/H` | 「특수 수량」 | ⚠ **용도 미확인** — 필드명 외 대응 라벨·설명·표본 없음 | ## 9. 남은 것 diff --git a/resources/knowledge/original/경쟁사 프로그램/STmate 분석/20_분석/35_형식과_단계의_공통과_차이.md b/resources/knowledge/original/경쟁사 프로그램/STmate 분석/20_분석/35_형식과_단계의_공통과_차이.md index 94bb81f8..e52df959 100644 --- a/resources/knowledge/original/경쟁사 프로그램/STmate 분석/20_분석/35_형식과_단계의_공통과_차이.md +++ b/resources/knowledge/original/경쟁사 프로그램/STmate 분석/20_분석/35_형식과_단계의_공통과_차이.md @@ -1,6 +1,6 @@ # 원가계산 4형식과 설계 뒤 네 단계 — 공통과 차이 -점검일: 2026-09-13 +점검일: 2026-09-14 근거: [12번](12_원가계산_4형식과_RATE표_복원.md) · [27번](27_계약_실행_기성_단계.md) · [06번](06_규칙1_RATE와_규칙7_간접비_끝전조정.md) · [08번](08_규칙6_8_9_재계산_변경설계_일괄변경.md) ## 결론 @@ -8,7 +8,7 @@ **「공식 집합이 통째로 다르다」는 표현은 지나치다.** 네 형식 모두 `직접공사비 → 간접비 → 일반관리비 → 이윤 → 공급가액·부가세`의 큰 사슬을 공유한다. 차이는 직접비를 묶는 방식, 기타경비의 묶음/세분, 일부 항목의 존재, 밑수와 요율표이다. - 큰 계산 단계: **6단계 중 6단계 공통(10할)** -- 화면상 간접비 항목: **공통 핵심 15개 / 합집합 25개(6할)** +- 화면상 간접비 항목: **공통 핵심 16개 / 합집합 25개(6.4할)** - 따라서 구현 관점에서는 **공통 계산 사슬 + 형식별 항목·밑수·요율 설정**으로 읽는 것이 근거에 맞다. ## 1. 네 형식 비교 @@ -22,21 +22,21 @@ | 산재·고용보험 | 총노무비 × 요율 | 같음(직노+간노로 명시) | 같음 | 같음(직노+간노로 명시) | | 건강·장기요양·연금·퇴직공제 | 같음 | 같음 | 같음 | 같음 | | 산업안전보건관리비 | 재료+직노+도급자관급자재 | 같음 | 직접공사비+도급자관급자재 | 같음 | -| 안전·품질관리비 | 별도내역 | 같음 | 같음 | 품질은 있음; 안전관리비는 목록에 없어 **미확인** | +| 안전·품질관리비 | 별도내역 | 같음 | 같음 | 같음 | | 기타성 경비 | `기타경비` 한 항목, 재료+노무비 | 8개 경비로 분리, 항목마다 5개 밑수 중 선택 | `기타경비` 한 항목, 직접공사비 | 8개 경비로 분리, 항목마다 5개 밑수 중 선택 | | 이행·하도급·환경·기계대여 보증 등 | 공통 항목군 | 공통 항목군 | 공통 항목군 | 공통 항목군 | | 보증·환경 항목 밑수 | 하도급·환경·기계대여는 재료+직노+산출경비 | 같음 | 하도급·기계대여는 직접공사비; 환경은 재료+직노+산출경비 | 재료+직노+산출경비 | | 일반관리비 | 순공사원가+관리품목자재대 | 순공사비+간노+경비 | 직접공사비+간접공사비+관리품목자재 | 직접공사비+간접노무비 | -| 이윤 | 소계-재료비 | 순공사원가+일반관리비-재료비 | 복원식은 직접+간접공사비+일반관리비, 화면 문구와 충돌해 **미확인** | `TA`와 같은 충돌로 **미확인** | +| 이윤 | 소계-재료비 | 순공사원가+일반관리비-재료비 | (직접공사비+간접비+일반관리비)×요율 | 같음 | | 공급가액·부가세·총액 | 같음 | 같음 | 같음 | 같음 | > 12번의 `TA 환경보전비=직접공사비` 기술은 06번의 후속 정정과 충돌한다. 이 표는 06번의 명시적 정정인 `재료비+직접노무비+산출경비`를 따른다. -### 공통 핵심 15개 +### 공통 핵심 16개 -간접노무비, 산재보험료, 고용보험료, 건강보험료, 장기요양보험료, 연금보험료, 퇴직공제부금비, 산업안전보건관리비, 품질관리비, 공사이행수수료, 하도급대금지급보증수수료, 환경보전비, 건설기계대여금지급보증수수료, 석면피해구제분담금, 임금채권부담금이다. +간접노무비, 산재보험료, 고용보험료, 건강보험료, 장기요양보험료, 연금보험료, 퇴직공제부금비, 산업안전보건관리비, 안전관리비, 품질관리비, 공사이행수수료, 하도급대금지급보증수수료, 환경보전비, 건설기계대여금지급보증수수료, 석면피해구제분담금, 임금채권부담금이다. -합집합 25개는 이 15개에 안전관리비, 일반/실적일반의 기타경비 1개, 수공 계열의 세부 경비 8개를 더한 화면상 항목 수이다. `SA`의 안전관리비 존재가 미확인이므로 **6할은 최대 1개 항목의 집계 오차 가능성**이 있다. +합집합 25개는 이 16개에 일반/실적일반의 기타경비 1개와 수공 계열의 세부 경비 8개를 더한 화면상 항목 수이다. `SA`의 화면에는 `9) 안전관리비 <별도내역>` 행과 `Disp_ANJUN_SA`, `Ed_C_AnJun_G_SA` 등 전용 컨트롤이 함께 있다. ## 2. 형식별 고유 차이 @@ -44,8 +44,8 @@ |---|---|---|---| | 일반 `T` | 기타경비를 한 항목으로 계산; 고용개선지원비(PS단가) 별도 묶음 | 기타경비=재료+노무; 일반관리비에 관리품목자재 포함; 이윤에서 재료 제외 | `RATE_R`, `RATE_R1` + 공통 보조표 | | 수공 `S` | 기타경비 대신 수도광열·복리후생·소모품/사무용품·여비/교통/통신·세금/공과·폐기물·도서인쇄·지급수수료 8개 | 각 세부 경비가 `재/직노/노/재+직노/재+노` 중 선택; 일반관리비·이윤 밑수도 수공식 | `RATE_R`, `RATE_R1`, `RateB` + 공통 보조표 | -| 실적일반 `TA` | 직접공사비 묶음 사용; 기타경비 한 항목; 국가유산 공종 없음; 노무형태·조정계수·재료비 방식 축 추가 | 안전비·기타경비 및 일부 보증은 직접공사비 계열; 일반관리비 밑수 변경; 이윤 밑수는 **미확인** | `RATE_RA`, `RATE_RA1`, `An_A`, `An_A1` + 공통 보조표 | -| 실적수공 `SA` | 실적 직접공사비 묶음+세부 경비 8개; 안전관리비 존재 **미확인**; 실적 추가축 사용 | 세부 경비는 `RateB`; 일반관리비는 직접공사비+간접노무비; 이윤 밑수는 **미확인** | `RATE_RA`, `RATE_RA1`, `RateB`, 실적 안전표 + 공통 보조표 | +| 실적일반 `TA` | 직접공사비 묶음 사용; 기타경비 한 항목; 국가유산 공종 없음; 노무형태·조정계수·재료비 방식 축 추가 | 안전비·기타경비 및 일부 보증은 직접공사비 계열; 일반관리비 밑수 변경; 이윤=(직접공사비+간접비+일반관리비)×요율 | `RATE_RA`, `RATE_RA1`, `An_A`, `An_A1` + 공통 보조표 | +| 실적수공 `SA` | 실적 직접공사비 묶음+세부 경비 8개; 안전관리비 별도내역; 실적 추가축 사용 | 세부 경비는 `RateB`; 일반관리비는 직접공사비+간접노무비; 이윤은 `TA`와 같음 | `RATE_RA`, `RATE_RA1`, `RateB`, 실적 안전표 + 공통 보조표 | 공통 보조표는 고용보험, 산업안전, 환경, 이행보증, 하도급보증, 기계대여보증 등의 항목별 표이다. 즉 `T/S`, `TA/SA`가 각각 규모·기간 요율표를 공유하고, `S/SA`가 세부 경비 밑수표를 추가로 공유한다. @@ -67,18 +67,21 @@ | 확정 | 건강보험료 | 2025 변경 표본에서 직접노무비가 증가했지만 당초=변경 | | 확정 | 노인장기요양보험료 | 같은 표본에서 당초=변경 | | 확정 | 연금보험료 | 같은 표본에서 당초=변경 | +| 적용 대상 확정 | 퇴직공제부금비 | 화면 힌트가 당초금액 적용·직접입력 옵션 대상 네 항목 중 하나로 명시 | | 재계산 확정 | 간접노무비·산재보험료·고용보험료 | 같은 표본에서 변경 직접노무비에 맞춰 재계산됨 | -| 미확인 | 위 항목 이외 전부 | 추가 `carry_original` 실데이터가 없음 | +| 미확인 | 위 네 항목 이외 전부 | 원문 화면이 다른 항목을 당초금액 적용 대상으로 명시하지 않고 추가 실데이터도 없음 | -보험료 변경 화면에는 `0 직접노무비×요율`, `1 당초액+직접노무비 증감×요율`, `2 당초액+증감액(비율)`의 세 모드가 있다. 그러나 **어느 보험 항목에 어느 모드가 허용·선택되는지**는 정적 자료만으로 전부 확정되지 않았다. 현재 확정 가능한 답은 **건강·장기요양·연금 외 추가 항목 없음**이 아니라 **추가로 확인된 항목 없음**이다. +보험료 변경 화면에는 `0 직접노무비×요율`, `1 당초액+직접노무비 증감×요율`, `2 당초액+증감액(비율)`의 세 모드가 있다. 화면 힌트는 이 계산방식과 당초금액 적용 대상을 **건강보험료·연금보험료·노인장기요양보험료·퇴직부금비**로 명시한다. 표본으로 실제 `carry_original`이 확인된 것은 앞의 세 항목이고, 퇴직공제부금비는 **적용 가능한 대상임이 원문으로 확인**된 상태다. -## 5. 미확인 6건 +## 5. 미확인 6건 재점검 결과 -1. `TA/SA` 이윤 밑수: 복원식과 화면의 `(노+경+일)×율` 문구가 충돌한다. -2. `SA`에 안전관리비가 실제로 빠지는지, 복원 목록의 번호 누락인지. -3. 변경설계에서 건강·장기요양·연금 외 `carry_original` 대상 및 모드 선택 조건. -4. 계약 단가 적용 결과의 원 단위 실증. -5. 실행예산 전체 계산과 기성 회차별 값·`QTY1_B/N/O/H`의 정확한 뜻. -6. 준공금액의 별도 계산 여부와 최종 누계 이관 규칙. +| 기존 번호 | 결과 | 원문 근거 | +|---:|---|---| +| 1 | **확인** — `TA/SA` 이윤=(직접공사비+간접비+일반관리비)×요율 | `TWM_KANJUB.dfm/json`의 두 형식 화면 캡션이 복원식과 일치. 요율표의 짧은 문구보다 실제 형식 화면을 채택 | +| 2 | **확인** — `SA`에 안전관리비 있음 | 같은 원문의 `9) 안전관리비 <별도내역>` 및 `SA` 전용 표시·입력 컨트롤 | +| 3 | **확인** — 기존 세 항목 외 퇴직공제부금비가 당초금액 적용 대상 | 같은 원문의 옵션 힌트가 네 항목을 열거. 그 밖의 대상은 원문에 없음 | +| 4 | **미확인** — 계약 단가 적용 결과의 원 단위 실증 | 경쟁사 프로그램 아래 계약 STC/XLSX 표본 없음 | +| 5 | **미확인** — 실행예산 전체 값, 기성 회차별 값, `QTY1_B/N/O/H` 뜻 | 실행·기성 STC/XLSX 표본 없음. 네 이름은 DB 필드명으로만 있고 대응 라벨·설명 없음 | +| 6 | **미확인** — 준공금액 계산/최종 누계 이관 규칙 | 준공 STC/XLSX 표본과 계산식 원문 없음 | -이 여섯 건은 표본 또는 통제 동적 시험 없이 추정해 채우지 않는다. +따라서 **3건 확인, 3건 미확인**이다. 특히 `QTY1_B/N/O/H`를 기성 수량으로 보는 기존 문구는 원문으로 입증되지 않았으므로 뜻을 계속 미확인으로 둔다. diff --git a/resources/library_structure/masonry_wet.json b/resources/library_structure/masonry_wet.json index b9c1cc9a..bf4248e7 100644 --- a/resources/library_structure/masonry_wet.json +++ b/resources/library_structure/masonry_wet.json @@ -19,7 +19,13 @@ "source": "judged_face_slope", "note": "사용자 값이 있으면 그 값, 없으면 품셈 13-4-4 [주]⑪ 표준경사를 전개가 판정해 넘김" }, - "L3": { "label": "뒷길이(㎝)", "option": "back_len_cm", "default": 45 }, + "L3": { + "label": "뒷길이(㎝)", + "option": "back_len_cm", + "default": 45, + "default_from": "standard_back_length", + "note": "안 고르면 품셈 13-4-4 [주]⑩ 뒷길이 표준 하한(높이·메/찰 · 범위 안 가장 작은 규격) — 전개와 한 벌(2026-09-14 브레인 판정 ㉮). 45 는 표를 못 읽을 때만" + }, "돌종류": { "label": "돌 종류", "option": "stone_kind", "default": "" }, "계수기준": { "label": "야면석 계수", "option": "stone_coeff_basis", "default": "" }, "TOP_IN": { diff --git a/resources/tester/fixtures/rates_2024_uljin.json b/resources/tester/fixtures/rates_2024_uljin.json new file mode 100644 index 00000000..068e586e --- /dev/null +++ b/resources/tester/fixtures/rates_2024_uljin.json @@ -0,0 +1,73 @@ +{ + "schema_version": "1.0", + "dataset_id": "rates_2024_fixture", + "effective_date": "2024-01-01", + "_note": "검산 전용 픽스처 — 정본 아님. 울진 공통(2024) 공사원가계산서를 재현해 「연도별 요율 교체 구조가 실제로 도는가」를 시험한다. 값 출처는 그 서류의 관측 요율(PLAN 8-9)이며, 프로그램 기본값으로 쓰지 않는다.", + "variables": { + "rate_indirect_labor": { + "base": "direct_labor_cost", + "brackets": [ + { + "direct_cost_bracket": "lt_1_billion", + "duration_bracket": "lte_183_days", + "work_type": "civil", + "rate_percent": 14.5 + } + ] + }, + "rate_sanjae": { "rate_percent": 3.56, "base": "total_labor_cost" }, + "rate_goyong": { + "base": "total_labor_cost", + "brackets": [ + { "grade": "below_7", "estimated_amount_bracket": "below_official_threshold", "rate_percent": 1.01 } + ] + }, + "rate_health": { "rate_percent": 3.545, "base": "direct_labor_cost" }, + "rate_care": { "rate_percent": 12.95, "base": "health_insurance_amount" }, + "rate_pension": { + "base": "direct_labor_cost", + "annual_rates": [{ "year": 2024, "rate_percent": 4.5 }] + }, + "rate_safety_pct": { + "base_with_owner_supplied_material": "minimum_of_statutory_two_formulas", + "brackets": [ + { + "target_amount_bracket": "500_million_to_5_billion", + "work_type": "civil", + "rate_percent": 1.86, + "base_amount_krw": 5349000 + }, + { "target_amount_bracket": "lt_500_million", "work_type": "civil", "rate_percent": 2.35 } + ] + }, + "rate_other_expense": { + "base": "material_cost_plus_total_labor_cost", + "brackets": [ + { + "direct_cost_bracket": "lt_1_billion", + "duration_bracket": "lte_183_days", + "work_type": "civil", + "rate_percent": 6.0 + } + ] + }, + "rate_environment": { + "base": "direct_construction_cost", + "minimum_estimated_amount_krw": 100000000, + "all_work_types": [{ "work_type": "civil_road", "rate_percent": 0.8 }] + }, + "rate_equipment_payment_guarantee": { + "general_construction": [{ "work_type": "civil_general", "rate_percent": 0.4 }], + "specialty_construction": [] + }, + "rate_overhead": { + "base": "material_plus_labor_plus_expense", + "civil_landscape_industrial": [{ "estimated_price_bracket": "lt_5_billion", "rate_percent": 6.0 }] + }, + "rate_profit": { + "base": "labor_plus_expense_plus_overhead", + "brackets": [{ "estimated_price_bracket": "lt_5_billion", "rate_percent": 15.0 }] + }, + "rate_vat": { "rate_percent": 10.0, "base": "construction_cost_before_vat" } + } +} diff --git a/resources/tester/test_b05_empty_means_select.py b/resources/tester/test_b05_empty_means_select.py index 54a0a7e6..99e781a7 100644 --- a/resources/tester/test_b05_empty_means_select.py +++ b/resources/tester/test_b05_empty_means_select.py @@ -24,6 +24,9 @@ if str(ROOT) not in sys.path: from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402 PANEL = (ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Panel.ts").read_text(encoding="utf-8") +#: 2026-09-14 칸 만들기가 `_Structures_Fields.optionControl` 로 옮겨감(브레인 판정 ①② — 고르기 칸은 +#: 늘 빈 보기 · 기본값은 제안으로만). +FIELDS = (ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Fields.ts").read_text(encoding="utf-8") API = (ROOT / "B05_Profile" / "B05_Profile_Api_Structures.ts").read_text(encoding="utf-8") COMMIT = (ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Panel_Commit.ts").read_text( encoding="utf-8" @@ -39,22 +42,24 @@ def _registry_options(type_id: str) -> dict[str, dict]: def test_폼이_empty_means_를_보고_빈_보기를_둔다() -> None: - """소스 검사 — 빈 보기와 초기값이 **같은 판정**(`keepEmpty`)을 쓴다.""" - assert "option.empty_means" in PANEL - assert "const keepEmpty = mustPick || meansEmpty;" in PANEL - assert 'if (keepEmpty) choices.unshift(["", mustPick ? "— 선택 —" : "— 안 정함 —"]);' in PANEL - # 빈 보기만 넣고 초기값을 첫 보기로 두면 화면은 「안 정함」인데 값은 첫 보기가 된다. - assert 'String(preset || (keepEmpty ? "" : (option.choices[0] ?? "")))' in PANEL + """소스 검사 — 고르기 칸은 **늘** 빈 보기를 두고, 초기값은 저장된 값뿐(첫 보기를 안 박음).""" + assert "optionControl(option, values[option.key])" in PANEL + assert 'select([["", blank], ...option.choices' in FIELDS + # 초기값은 저장된 값만 — 첫 보기·기본값으로 채우지 않음. + assert "element.value = value;" in FIELDS + assert "option.choices[0]" not in PANEL # 클라이언트 옵션 타입에 칸이 있어야 서버 응답의 `empty_means` 가 살아 온다. assert "empty_means?: string | null;" in API # 빈 칸은 아예 안 실린다 — 이 규칙이 있어야 「안 정함」이 정본에 안 박힌다. assert "if (input.isEmpty()) return;" in COMMIT -def test_기본값_있는_칸은_빈_보기를_안_둔다() -> None: - """`empty_means` 가 적혀 있어도 **기본값이 있으면** 그 값이 뜬다(2026-08-17 지시 유지).""" +def test_기본값_있는_칸도_빈_보기에_제안으로만_보인다() -> None: + """⭐ 2026-09-14 브레인 판정으로 뒤집힘 — 기본값이 **있어도** 칸에 채우지 않고 빈 보기 이름에 + 「제안 …」으로만 보임(옛 2026-08-17 「첫 보기가 곧 기본값」 걷음). 저장 한 번에 기본값이 + 확정으로 굳던 뿌리였음.""" options = _registry_options("masonry_wet") - # 버림 콘크리트 — empty_means 가 있으나 기본이 「넣음」이다. + # 버림 콘크리트 — empty_means 가 있고 기본이 「넣음」. assert options["blinding_concrete"]["empty_means"] assert options["blinding_concrete"]["default"] == "넣음" # 채움 강도·야면석 계수 — 기본이 없어 빈 보기가 서야 하는 칸. @@ -62,7 +67,7 @@ def test_기본값_있는_칸은_빈_보기를_안_둔다() -> None: assert options[key]["empty_means"], key assert options[key]["default"] is None, key assert options[key]["required"] is False, key - assert '(option.default ?? "") === ""' in PANEL # 기본값 있으면 빈 보기를 안 둠 + assert "`— 안 정함 (제안 ${suggested}) —`" in FIELDS def _wall(**options) -> dict: diff --git a/resources/tester/test_b05_facility_options_merge.py b/resources/tester/test_b05_facility_options_merge.py new file mode 100644 index 00000000..9b4a4593 --- /dev/null +++ b/resources/tester/test_b05_facility_options_merge.py @@ -0,0 +1,118 @@ +"""B05 시설 [저장]이 폼이 모르는 칸을 안 지우는가 (2026-09-14 브레인 판정). + +⚠ 앞서 저장이 옵션을 통째로 갈아 끼워, 구조물 집계표·구조물도로 적은 칸(관 기슭막이 기초 등)이 + B05 에서 그 시설을 한 번 더 저장하면 조용히 지워졌음 — 되돌릴 길이 없는 사고. +짝: `B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts` — 실제 TS 를 옮겨 돌림. +""" + +from __future__ import annotations + +import json +import re +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" +MERGE_TS = ROOT / "B05_Profile" / "B05_Profile_UI_Drainage_Facility_Merge.ts" +FORM_TS = ROOT / "B05_Profile" / "B05_Profile_UI_Drainage_Facility.ts" + +RUNNER = """ +import { readFileSync, writeFileSync } from "node:fs"; +import { FORM_OPTION_KEYS, mergeFacilityOptions } from "./B05_Profile_UI_Drainage_Facility_Merge.js"; +const cases = JSON.parse(readFileSync(process.argv[2], "utf8")); +const out = cases.map(([previous, next]) => mergeFacilityOptions(previous, next)); +writeFileSync(process.argv[3], JSON.stringify({ out, keys: FORM_OPTION_KEYS })); +""" + +PIPE_BEFORE = { + "facility": "pipe", + "options": { + "pipe_diameter_mm": 1000, + "inlet_type": "집수정", + "inlet_basin_form": "□형(기본형)", + "revet_foundation": "기초유", # 집계표로 적은 칸 — 폼에 없음 + "inlet_basin_before_m": 1.5, # 횡단(B06)이 적는 칸 — 폼에 없음 + }, +} +# 폼이 다시 저장 — 유입구를 기슭막이로 바꿔 집수정 칸은 안 보냄 +PIPE_FORM = { + "facility": "pipe", + "options": { + "pipe_diameter_mm": 800, + "inlet_type": "기슭막이", + "inlet_revet_form": "돌쌓기(찰)", + }, +} +REVET_BEFORE = {"facility": "revetment", "options": {"side": "양쪽", "back_len_cm": "35"}} +TYPE_CHANGE = {"facility": "ford_pavement", "options": {"ford_width_m": 5}} + + +@pytest.fixture(scope="module") +def merged(tmp_path_factory) -> dict: + if not TSC.is_file(): + pytest.skip("프론트엔드 의존성 미설치") + out = tmp_path_factory.mktemp("merge_js") + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(TSC), + str(MERGE_TS), + "--outDir", + str(out), + "--module", + "esnext", + "--target", + "es2022", + "--moduleResolution", + "bundler", + "--ignoreConfig", + ], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + (out / "runner.mjs").write_text(RUNNER, encoding="utf-8") + cases = [ + [PIPE_BEFORE, PIPE_FORM], + [REVET_BEFORE, {"facility": "revetment", "options": {"side": "좌"}}], + [PIPE_BEFORE, TYPE_CHANGE], + [None, PIPE_FORM], + ] + (out / "cases.json").write_text(json.dumps(cases, ensure_ascii=False), encoding="utf-8") + subprocess.run( # noqa: S603 + ["node", str(out / "runner.mjs"), str(out / "cases.json"), str(out / "result.json")], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + return json.loads((out / "result.json").read_text(encoding="utf-8")) + + +def test_폼이_모르는_칸은_남고_폼이_아는_칸은_폼대로(merged) -> None: + options = merged["out"][0]["options"] + assert options["revet_foundation"] == "기초유" # 집계표 값이 살아남음 + assert options["inlet_basin_before_m"] == 1.5 + assert options["pipe_diameter_mm"] == 800 # 폼 값이 이김 + assert "inlet_basin_form" not in options # 폼이 아는 칸을 폼이 비움 → 지움 + assert merged["out"][1]["options"] == {"side": "좌", "back_len_cm": "35"} + + +def test_시설_종류를_바꾸면_통째로_갈아_끼움(merged) -> None: + assert merged["out"][2] == TYPE_CHANGE + assert merged["out"][3] == PIPE_FORM + + +def test_폼이_적는_칸이_모두_목록에_있다(merged) -> None: + """폼에 칸을 늘리고 목록에 안 넣으면 여기서 잡힘.""" + source = FORM_TS.read_text(encoding="utf-8") + body = source[source.index("readOptions() {") :] + branches = re.split(r'current === "(\w+)"', body)[1:] + assert branches[::2] == ["pipe", "box_culvert", "ford_pavement", "ford_bridge", "revetment"] + for facility, block in zip(branches[::2], branches[1::2]): + written = set(re.findall(r'putNumber\(options, "(\w+)"', block)) + written |= set(re.findall(r"options\.(\w+) =", block)) + missing = written - set(merged["keys"][facility]) + assert not missing, f"{facility}: 폼이 적는데 목록에 없음 {missing}" diff --git a/resources/tester/test_b05_not_in_table_marks.py b/resources/tester/test_b05_not_in_table_marks.py new file mode 100644 index 00000000..358c0dd9 --- /dev/null +++ b/resources/tester/test_b05_not_in_table_marks.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- +"""「칸은 있는데 표가 안 읽음」 — 등록부 칸 옆에 「아직 표에 안 쓰임」 (2026-09-14 브레인 차례 ①). + +사용자가 값을 넣어도 수량·금액이 아무것도 안 바뀌는 칸. 사유를 표에만 두지 말고 칸 옆에도 보임 +(브레인 판정 ③ 「B」). 표시가 참말인지 **값을 바꿔 돌려 보고** 지킴 — 표가 그 칸을 읽기 시작하면 +여기서 깨지고, 그때 표시를 걷음(표시가 거짓말로 남지 않게). +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B05_Profile.B05_Profile_Structures_Schema import structure_type_map # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402 + +#: 종류 → (표가 서는 기준 제원, 안 읽는 칸). 2026-09-14 값을 바꿔 전수 대조한 결과. +#: ⚠ 기초(foundation)·높이는 인계 구조물터파기 심도 구간이 읽어 뺌 · 단 수·올림·이동은 횡단도 칸이라 뺌. +MARKED: dict[str, tuple[dict, list[str]]] = { + "erosion_check": ( + {"height_m": 2.0, "top_length_m": 6.0, "bottom_length_m": 4.0, "form": "돌"}, + ["thickness_top_m", "thickness_bottom_m", "stone_supply", "stone_coeff_basis"], + ), + "bed_sill": ( + {"area_m2": 10.0, "form": "돌붙임(찰)"}, + ["stone_supply", "stone_coeff_basis", "fill_concrete_mpa", "face_slope_ratio"], + ), + "boulder_masonry": ( + {"height_m": 2.5, "length_m": 10.0, "stone_cm": "60~80", "bond": "찰쌓기"}, + [ + "stone_supply", + "stone_coeff_basis", + "fill_concrete_mpa", + "weep_hole_diameter_mm", + "weep_hole_area_m2", + ], + ), + "soil_guard": ( + {"height_m": 1.0, "length_m": 10.0, "form": "떼"}, + [ + "thickness_top_m", + "thickness_bottom_m", + "back_len_cm", + "stone_kind", + "stone_supply", + "stone_coeff_basis", + "fill_concrete_mpa", + "face_slope_ratio", + "weep_hole_diameter_mm", + "weep_hole_area_m2", + ], + ), +} + +FIELDS = (ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Fields.ts").read_text(encoding="utf-8") +PANEL = (ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Panel.ts").read_text(encoding="utf-8") + + +def _snapshot(type_id: str, options: dict) -> str: + structure = { + "structure_id": "p", + "type_id": type_id, + "start_m": 0.0, + "end_m": 10.0, + "chainage_m": 5.0, + "options": options, + } + table = build_table([structure], {}, {5.0: "both_fill"}, None, 0.2) + # 저장 제원을 그대로 되돌려 싣는 칸(`options`)은 뺌 — 값이 **표에 닿았는지**만 봄. + for row in table["structures"]: + row.pop("options", None) + return json.dumps(table, ensure_ascii=False, sort_keys=True, default=str) + + +def _probe_values(option) -> list: + if option.choices: + return list(option.choices) + return [0.73, 1.37] if option.input == "number" else ["x"] + + +@pytest.mark.parametrize("type_id", sorted(MARKED)) +def test_등록부에_표시가_있다(type_id: str) -> None: + options = {option.key: option for option in structure_type_map()[type_id].options} + for key in MARKED[type_id][1]: + note = options[key].not_in_table + assert note and "아직 표에 안 쓰임" in note, f"{type_id}.{key}" + + +@pytest.mark.parametrize("type_id", sorted(MARKED)) +def test_표시한_칸은_정말_안_읽힌다(type_id: str) -> None: + base, keys = MARKED[type_id] + options = {option.key: option for option in structure_type_map()[type_id].options} + reference = _snapshot(type_id, dict(base)) + assert '"components": []' not in reference, "기준 제원으로 표가 안 섬 — 시험이 무의미" + for key in keys: + for value in _probe_values(options[key]): + assert _snapshot(type_id, {**base, key: value}) == reference, ( + f"{type_id}.{key}={value!r} 로 표가 바뀜 — 이제 읽힘, 표시를 걷을 것" + ) + + +def test_폼_칸_이름에_붙는다() -> None: + assert "not_in_table" in FIELDS and "not_in_table" in PANEL diff --git a/resources/tester/test_b05_registry_fill_a2_a3.py b/resources/tester/test_b05_registry_fill_a2_a3.py new file mode 100644 index 00000000..4bbd8da1 --- /dev/null +++ b/resources/tester/test_b05_registry_fill_a2_a3.py @@ -0,0 +1,60 @@ +"""등록부 칸 채우기 A2·A3 (2026-09-14) — 표가 읽는 칸이 등록부에 없어 영영 안 서던 자리. + +A2 개거(점 배치) — 연장 칸 · A3 물넘이포장(관 지점 시설) — 두께·노폭 방향 길이 칸. +⚠ 옛 저장본(칸 없음)은 안 깨지고 **사유가 보임** — 「값이 없으면 표가 안 선다」가 아니라 「사유를 보인다」. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B05_Profile.B05_Profile_Structures_Repository import _validate_types # noqa: E402 +from B05_Profile.B05_Profile_Structures_Schema import StructureInstance, structure_type_map # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_Pipe import facility_structures # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402 + + +def _ditch(**options) -> dict: + return build_table( + [{"structure_id": "d", "type_id": "open_ditch", "chainage_m": 10.0, "options": options}] + )["structures"][0] + + +def test_개거_연장_칸이_등록부에_서고_구조물_목록에_저장된다() -> None: + keys = {option.key for option in structure_type_map()["open_ditch"].options} + assert "length_m" in keys + item = StructureInstance( + type_id="open_ditch", placement="point", chainage_m=10.0, options={"length_m": 12} + ) + _validate_types([item]) # 등록부에 없는 칸이면 여기서 거절됨 + + +def test_개거_연장이_있으면_표가_서고_없으면_어디서_적는지_사유() -> None: + assert _ditch(length_m=12)["components"] + old = _ditch() # 옛 저장본 — 칸이 없음 + assert not old["components"] + assert old["notes"][0].startswith("개거(겉도랑) 연장(m)이(가) 아직 입력되지 않았습니다") + + +def _ford(**options) -> dict: + point = {"facility": "ford_pavement", "chainage_m": 30.0, "options": options} + return build_table(facility_structures([point]))["structures"][0] + + +def test_물넘이포장_두께_길이_칸이_있으면_면적으로_선다() -> None: + keys = {option.key: option for option in structure_type_map()["ford_pavement"].options} + assert keys["thickness_cm"].phase == "detail" and keys["length_m"].phase == "detail" + row = _ford(ford_width_m=5, thickness_cm=20, length_m=8) + assert row["components"] and (row["billing_unit"], row["billing_quantity"]) == ("㎡", 40.0) + + +def test_물넘이포장_칸이_비면_무엇을_적을지_사유() -> None: + old = _ford(ford_width_m=5) + assert not old["components"] and "포장 두께(㎝)" in old["notes"][0] + no_length = _ford(ford_width_m=5, thickness_cm=20) + assert not no_length["components"] and "포장 길이" in no_length["notes"][0] diff --git a/resources/tester/test_b05_retaining_wall_height_default.py b/resources/tester/test_b05_retaining_wall_height_default.py new file mode 100644 index 00000000..9d0435b5 --- /dev/null +++ b/resources/tester/test_b05_retaining_wall_height_default.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +"""옹벽 높이 제안값 2.0 — A4 (2026-09-14 브레인 차례 ③). + +등록부 옹벽 높이 기본(=폼의 회색 제안값)이 2.5 였는데 관측 원단위 자료는 반중력식 H=2.0 한 벌뿐 → +[제안값 넣기]를 누르면 표가 안 서는 값이 들어가 「원단위 미확보」 사유만 뜸. 자료가 있는 2.0 으로 내림. +⚠ 기본값은 여전히 **제안**일 뿐 — 칸에 채우지 않음(폼 판정 ①②). +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B05_Profile.B05_Profile_Structures_Schema import structure_type_map # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import expand # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_ObservedUnit import load_observed_table # noqa: E402 + + +def _height_default() -> float: + options = {option.key: option for option in structure_type_map()["retaining_wall"].options} + return options["height_m"].default + + +def test_옹벽_높이_제안값은_자료가_있는_2_0() -> None: + assert _height_default() == 2.0 + + +def test_제안값을_넣으면_표가_선다() -> None: + structure = { + "structure_id": "rw", + "type_id": "retaining_wall", + "start_m": 0.0, + "end_m": 10.0, + "options": {"form": "반중력식", "height_m": _height_default(), "length_m": 10.0}, + } + result = expand(structure, {"retaining_wall": "옹벽"}, load_observed_table()) + assert result.components, result.notes diff --git a/resources/tester/test_b05_revetment_sabang_basis.py b/resources/tester/test_b05_revetment_sabang_basis.py new file mode 100644 index 00000000..da087535 --- /dev/null +++ b/resources/tester/test_b05_revetment_sabang_basis.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +"""독립 기슭막이 치수 한 벌 — B05 칸 밑 근거와 B08 사유가 **같은 파일**을 읽음 (2026-09-14 브레인 판정). + +한 자리 = `resources/data_masonry/revetment_sabang_*.json`. TS 는 판 이름을 import 로 박으므로 판이 +바뀌면 import 도 옮겨야 함 — 여기서 최신 판과 대조. 쪽·줄 번호가 사유에 그대로 실리는지도 봄. +""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_Pipe import ( # noqa: E402 + own_height_unconfirmed, + own_revetment_basis, +) + +DATA_DIR = ROOT / "resources" / "data_masonry" +FIELDS = (ROOT / "B05_Profile" / "B05_Profile_UI_Drainage_Facility_Fields.ts").read_text( + encoding="utf-8" +) +CHROME = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Card_Chrome.ts").read_text(encoding="utf-8") + + +def test_TS_가_최신_판을_읽는다() -> None: + latest = sorted(DATA_DIR.glob("revetment_sabang_*.json"))[-1].name + imported = re.search(r'from "\.\./resources/data_masonry/(revetment_sabang_[^"]+)"', FIELDS) + assert imported and imported.group(1) == latest + + +def test_TS_가_쓰는_칸이_파일에_있다() -> None: + latest = sorted(DATA_DIR.glob("revetment_sabang_*.json"))[-1] + data = json.loads(latest.read_text(encoding="utf-8")) + items = data["items"] + assert data["scope"] and "사방(계류) 교본 기준" in data["scope"] + assert items["height"]["rule"] == "계획홍수위 + 0.5~0.7m" + assert items["face_slope"]["standard_ratio"] == [0.3, 0.5] + assert items["crown_thickness"]["standard_m"] == [0.3, 0.5] + assert "L7164" in items["backfill_pebble"]["source"] + + +def test_B08_사유가_같은_근거를_쪽_줄_번호로() -> None: + basis = own_revetment_basis() + for ref in ("2-나:141", "3-가:181", "2-나:129", "3-가:151", "2-나:143", "L7164~7171"): + assert ref in basis, ref + assert "2-나:141" in own_height_unconfirmed() + + +def test_B05_높이_칸은_제안_없이_설계자_입력() -> None: + assert 'height: "",' in FIELDS and '"설계자 입력"' in FIELDS + assert "기슭막이 높이 없음 — 근입만 그림" in CHROME diff --git a/resources/tester/test_b05_structures_migration.py b/resources/tester/test_b05_structures_migration.py index c5f3bc49..a99c28ec 100644 --- a/resources/tester/test_b05_structures_migration.py +++ b/resources/tester/test_b05_structures_migration.py @@ -68,7 +68,8 @@ def test_client_format_gisungmagi_maps_to_revetment(): # 기준측점 전/후 5m — 레지스트리 기본값. assert (item.start_m, item.end_m) == (115.0, 125.0) assert item.options["form"] == "돌쌓기(메)" - assert item.options["height_m"] == 2.5 and item.options["length_m"] == 10 + # 높이는 설계자 입력 — 등록부 기본 2.5 걷음(2026-09-14 브레인 판정 · 사방기술교본 2-나:141) + assert "height_m" not in item.options and item.options["length_m"] == 10 def test_client_format_escape_route_width_field_wins(): diff --git a/resources/tester/test_b05_structures_registry_policy.py b/resources/tester/test_b05_structures_registry_policy.py index 0f5c2879..2f2c006c 100644 --- a/resources/tester/test_b05_structures_registry_policy.py +++ b/resources/tester/test_b05_structures_registry_policy.py @@ -255,7 +255,12 @@ def test_slope_group_computes_range_from_length(): # 생기며 required는 풀렸다). 후길이 = 길이 − 전길이는 화면 계산값이라 옵션이 # 아니다. 순서는 높이 → 길이 → 전길이. options = {option.key: option for option in item.options} - expected = {"height_m": 2.5, "length_m": 10, "before_m": 5} + # ⚠ 옹벽만 높이 2.0 — 관측 원단위 자료가 반중력식 H=2.0 한 벌뿐(2026-09-14 A4 브레인 판정). + expected = { + "height_m": 2.0 if type_id == "retaining_wall" else 2.5, + "length_m": 10, + "before_m": 5, + } for key, default in expected.items(): assert key in options, f"{type_id}.{key} 옵션 없음" assert options[key].phase == "b05", f"{type_id}.{key}는 b05 phase" @@ -451,10 +456,12 @@ def test_pipe_accessory_options_defined(): "outlet_pitching_area_m2", ): assert gone not in options, f"pipe.{gone}는 보호공으로 합쳐졌다" - # 기본 치수 — 길이 10m·높이 2.5m. + # 기본 치수 — 길이 10m. 높이는 **기본값 없음**(2026-09-14 브레인 판정): 비우면 관경 기준 최소 + # 높이로 횡단도·구조물도·표가 같은 값을 씀(옛 2.5 는 횡단도가 안 쓰던 근거 없는 값이라 걷음). for side in ("inlet", "outlet"): assert options[f"{side}_revet_length_m"].default == 10 - assert options[f"{side}_revet_height_m"].default == 2.5 + assert options[f"{side}_revet_height_m"].default is None + assert "관경 기준" in options[f"{side}_revet_height_m"].empty_means # 유무·길이·관경·유입 구조 = b05, 형태·치수 제원 = detail. for key in ("inlet_type", "inlet_revet_length_m", "outlet_revet_length_m"): assert options[key].phase == "b05", f"pipe.{key}는 b05 phase" @@ -483,7 +490,9 @@ def test_revetment_range_options_like_slope_group(): pipe_options = {option.key: option for option in by_id["pipe"].options} assert item.placement == "point" and item.managed_by == "pipe_points" - expected = {"height_m": 2.5, "length_m": 10, "before_m": 5, "after_m": 5} + # ⚠ 높이는 2026-09-14 부터 **설계자 입력**(기본 없음) — 계획홍수위 + 0.5~0.7m(사방기술교본 + # 2-나:141 · 3-가:181) · 옛 2.5 는 근거 없어 걷음(브레인 판정). + expected = {"height_m": None, "length_m": 10, "before_m": 5, "after_m": 5} for key, default in expected.items(): assert key in options, f"revetment.{key} 옵션 없음" assert options[key].phase == "b05" and not options[key].required, key diff --git a/resources/tester/test_b05_structures_router.py b/resources/tester/test_b05_structures_router.py index ab0d2ecb..be38399a 100644 --- a/resources/tester/test_b05_structures_router.py +++ b/resources/tester/test_b05_structures_router.py @@ -65,7 +65,8 @@ def test_structure_types_endpoint_returns_registry(client): # (기본값 원칙 ② — 구 저장분 저장 거부 재발 방지). 상세 정책은 registry_policy 테스트. wall = next(item for item in body["types"] if item["type_id"] == "retaining_wall") height = next(option for option in wall["options"] if option["key"] == "height_m") - assert height["required"] is False and height["default"] == 2.5 + # 2026-09-14 A4 — 옹벽 제안값은 자료가 있는 2.0(반중력식 H=2.0 한 벌뿐). + assert height["required"] is False and height["default"] == 2.0 def test_read_structures_empty_project(client): @@ -247,7 +248,8 @@ def test_migrate_converts_legacy_stations(client, tmp_path): assert pipe["facility"] == "revetment" and pipe["chainage_m"] == 120.0 # 구간·제원은 레지스트리 기본값 승계 — 기준측점 전/후 5m. assert (pipe["start_m"], pipe["end_m"]) == (115.0, 125.0) - assert pipe["options"]["form"] == "돌쌓기(메)" and pipe["options"]["height_m"] == 2.5 + # 높이는 설계자 입력 — 등록부 기본 2.5 걷음(2026-09-14 브레인 판정) + assert pipe["options"]["form"] == "돌쌓기(메)" and "height_m" not in pipe["options"] def test_migrate_without_pipe_points_defers_revetment(client): diff --git a/resources/tester/test_b06_b08_wall_thickness_match.py b/resources/tester/test_b06_b08_wall_thickness_match.py new file mode 100644 index 00000000..ff670cd5 --- /dev/null +++ b/resources/tester/test_b06_b08_wall_thickness_match.py @@ -0,0 +1,102 @@ +"""횡단도(B06)가 그리는 돌쌓기 벽 폭과 수량(B08)이 세는 벽 두께가 **같은 값인가** — 대조 시험. + +왜 (2026-09-14 브레인 차례 「두께 차이」) — 같은 다단 벽을 두 곳이 다른 두께로 봄: + B06 `REVET_THICKNESS_M` 0.45(울진 뒷길이 관측 · 표시용) → 윗폭 1.5×0.45 · 밑폭 윗폭 + 기울기×H + B08 `wall_thickness` 실무 구조물도 식 → 상부 뒷길이 + 0.30 · 하부 상부 + 0.30×(H−1) +어느 쪽으로 맞출지는 아직 안 정함 — 그래서 `xfail(strict)`: 지금은 빨강이 **나야** 하고, +두 값이 맞춰지면 통과로 뒤집혀 이 표시를 걷으라고 알림. +실제 화면 코드를 컴파일해 Node 로 돌림(`test_b06_extra_shift_floor` 와 같은 방식). +""" + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from B08_Quantity.B08_Quantity_Engine_UnitQuantity_StoneSpec import ( # noqa: E402 + _back_length, + wall_thickness, +) + +TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" +MODULE = PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Cross_Culvert_Extra.ts" +FORM = "돌쌓기(메)" +LEAN = 0.35 # 품셈 13-4-4 [주]⑪ 메쌓기 성토 ~3m + +_RUNNER = """ +const { buildOutletExtras } = require(process.argv[3]); +const { writeFileSync } = require("node:fs"); +const groundAt = (offset) => 95 - offset / 2; +const result = buildOutletExtras({ + start: { offset: 0, elevation: 100 }, + startBottomElevation: 100, + outward: 1, + groundAt, + limitOffset: 60, + adjusts: [{ x: 0, d: null, h: 2.0, m: "%(form)s" }], + leanFor: () => %(lean)s, +}); +const wall = result.walls[0]; +const [bottomBack, topBack, topFront, bottomFront] = wall.points; +writeFileSync(process.argv[2], JSON.stringify({ + pureHeight: wall.height + 0.5, + top: Math.abs(topFront.offset - topBack.offset), + bottom: Math.abs(bottomFront.offset - bottomBack.offset), +})); +""" + + +def _drawn(tmp_path: Path) -> dict: + out = tmp_path / "out" + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(TSC), + "--ignoreConfig", + "--target", + "es2022", + "--module", + "commonjs", + "--skipLibCheck", + "--outDir", + str(out), + str(MODULE), + ], + cwd=str(PROJECT_ROOT), + check=False, + capture_output=True, + ) + compiled = next(out.rglob("B06_Section_UI_Cross_Culvert_Extra.js"), None) + assert compiled is not None, "화면 코드가 JS 로 안 나옴 — tsc 실패" + (out / "runner.cjs").write_text(_RUNNER % {"form": FORM, "lean": LEAN}, encoding="utf-8") + result = tmp_path / "result.json" + subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(out / "runner.cjs"), str(result), str(compiled)], + cwd=str(PROJECT_ROOT), + check=True, + capture_output=True, + ) + return json.loads(result.read_text(encoding="utf-8")) + + +@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치") +@pytest.mark.xfail(strict=True, reason="B06 0.45 표시값 ↔ B08 구조물도 식 — 맞출 쪽 판정 대기") +def test_횡단도_벽_폭과_수량_벽_두께가_같다(tmp_path: Path) -> None: + drawn = _drawn(tmp_path) + height = drawn["pureHeight"] + options = {"form": FORM} + back_cm = _back_length(options, wet=False, height_m=height) + top, bottom, basis = wall_thickness(options, back_cm=back_cm, height_m=height) + assert (round(drawn["top"], 2), round(drawn["bottom"], 2)) == ( + round(top, 2), + round(bottom, 2), + ), ( + f"H {height:.2f} — 횡단도 윗폭 {drawn['top']:.3f} · 밑폭 {drawn['bottom']:.3f}" + f" ↔ 수량 {basis}" + ) diff --git a/resources/tester/test_b06_culvert_set.py b/resources/tester/test_b06_culvert_set.py index d5bf7c25..4a35b241 100644 --- a/resources/tester/test_b06_culvert_set.py +++ b/resources/tester/test_b06_culvert_set.py @@ -34,7 +34,11 @@ def test_cross_reference_constants_match_plan(): def test_empty_options_fall_back_to_registry_defaults(): - """옵션이 빈 저장분은 레지스트리 기본값(Ø1000·기슭막이 H2.5/L10)으로 채운다.""" + """옵션이 빈 저장분은 레지스트리 기본값(Ø1000·L10)으로 채운다. + + 높이는 2026-09-14 브레인 판정으로 **관경 기준 최소 높이**(Ø1000 → 1.5 + 근입 0.5 = 2.0) — + 횡단도가 그리는 그 높이. 옛 기본 2.5 는 횡단도가 안 쓰던 값이라 걷음. + """ spec = _culvert_set(None) assert spec["type"] == "pipe" assert spec["diameter_m"] == 1.0 @@ -42,11 +46,11 @@ def test_empty_options_fall_back_to_registry_defaults(): for side in ("inlet", "outlet"): part = spec[side] assert part["structure"] == "기슭막이" - assert part["revet_height_m"] == 2.5 + assert part["revet_height_m"] == 2.0 assert part["revet_length_m"] == 10 assert part["face_slope"] == REVET_FACE_SLOPE - # 보호공 = 낙차고(2.5) × 2 = 5.0m, 두께 0.45m(사용자 확정). - assert part["apron_length_m"] == 5.0 + # 보호공 = 낙차고(2.0) × 2 = 4.0m, 두께 0.45m(사용자 확정). + assert part["apron_length_m"] == 4.0 assert part["apron_thickness_m"] == 0.45 # 레지스트리 기본 형태: 유입 찰 / 유출 메. assert spec["inlet"]["revet_form"] == "돌쌓기(찰)" @@ -61,7 +65,7 @@ def test_basin_inlet_skips_revet_and_apron(): assert "apron_length_m" not in spec["inlet"] # 유출측은 그대로 세트를 갖춘다. assert spec["outlet"]["structure"] == "기슭막이" - assert spec["outlet"]["apron_length_m"] == 5.0 + assert spec["outlet"]["apron_length_m"] == 4.0 # 관경 기준 높이 2.0 × 2 def test_string_diameter_and_custom_height(): diff --git a/resources/tester/test_b06_detail_options.py b/resources/tester/test_b06_detail_options.py index 29fb04a1..9de4c45f 100644 --- a/resources/tester/test_b06_detail_options.py +++ b/resources/tester/test_b06_detail_options.py @@ -39,10 +39,13 @@ def test_B05_는_종전대로_유무_종류_위치만(): def test_기본값_없는_필수_항목은_빈_칸으로_연다(): """첫 항목을 슬쩍 고르면 **근거 없는 값**(뒷길이 25㎝ 같은)이 수량·단가로 흘러간다.""" panel = _PANEL.read_text(encoding="utf-8") - # 2026-09-13 — 빈 보기를 두는 까닭이 둘이 되어(필수 + `empty_means`) 한 줄로 합쳐졌다. + fields = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Fields.ts").read_text( + encoding="utf-8" + ) + # 2026-09-14 — 칸 만들기가 `optionControl` 로 옮겨감 · 고르기 칸은 **늘** 빈 보기(브레인 판정 ①). # 필수 칸의 글자는 그대로 「— 선택 —」이다. - assert 'choices.unshift(["", mustPick ? "— 선택 —" : "— 안 정함 —"])' in panel - assert 'option.required === true && (option.default ?? "") === ""' in panel + assert "optionControl(option, values[option.key])" in panel + assert '? "— 선택 —"' in fields and 'select([["", blank]' in fields def test_큰돌쌓기에_메_찰_구분이_있다(): diff --git a/resources/tester/test_b06_facility_default_notes.py b/resources/tester/test_b06_facility_default_notes.py new file mode 100644 index 00000000..3b704566 --- /dev/null +++ b/resources/tester/test_b06_facility_default_notes.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +"""BOX암거·세월교·물넘이포장 — **기본값으로 그린 칸을 그림에 적음** (2026-09-14 브레인 판정 ②③). + +폼이 기본값을 안 보내게 고친 뒤, 비운 칸은 횡단도가 등록부 기본값으로 그리고 있었음. 저장만 안 하면 +된다고 볼 일이 아님 — 그림이 설계값처럼 보이는 것이 같은 병. 세트 제원이 `defaulted` 「이름 값」을 +싣고 카드 머리가 「⚠ … 기본값(미확정)」으로 보임. 물넘이 월류 높이가 비면 파임을 안 그리는 까닭도 보임. +TS 짝은 거울 시험(`test_b06_culvert_sets_mirror`)이 딕셔너리째 대조함. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B06_Section.B06_Section_Engine_Culvert import ( # noqa: E402 + _box_set, + _ford_pavement_set, + _ford_set, +) + +CHROME = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Card_Chrome.ts").read_text(encoding="utf-8") +CONTROLS = (ROOT / "B06_Section" / "B06_Section_UI_Page_Ford_Controls.ts").read_text( + encoding="utf-8" +) + + +def test_BOX_빈_칸은_기본값_목록에_선다() -> None: + items = _box_set({})["defaulted"] + assert "본체 폭 2" in items and "본체 높이 2" in items + assert "유입 날개벽 있음" in items and "유출 날개벽 각도 45" in items + + +def test_적은_칸은_목록에서_빠진다() -> None: + items = _box_set({"body_width_m": 3.0, "wing_in": "없음"})["defaulted"] + assert not any(item.startswith("본체 폭 ") for item in items) + # 안 세운 날개벽은 치수를 안 봄. + assert not any(item.startswith("유입 날개벽") for item in items) + assert "본체 높이 2" in items + + +def test_세월교_월류_폭_관경도_적힌다() -> None: + items = _ford_set({"ford_width_m": 12.0})["defaulted"] + assert "관경 1000" in items and "월류 높이 0" in items + assert not any(item.startswith("월류 폭 ") for item in items) + + +def test_물넘이_월류_높이가_비면_파임_없음() -> None: + spec = _ford_pavement_set({}) + assert spec["depth_m"] is None + assert spec["defaulted"] == ["월류 폭 5"] + + +def test_카드_머리가_목록과_까닭을_보인다() -> None: + assert "기본값(미확정)" in CHROME + assert "월류 높이 없음 — 파임 안 그림" in CHROME + # 조작으로 값이 오면 목록에서 뺌 — 캐시를 바로 고치는 길. + assert CONTROLS.count("dropDefaulted(spec,") == 4 diff --git a/resources/tester/test_b06_pipe_wall_height_mirror.py b/resources/tester/test_b06_pipe_wall_height_mirror.py new file mode 100644 index 00000000..a41970e0 --- /dev/null +++ b/resources/tester/test_b06_pipe_wall_height_mirror.py @@ -0,0 +1,43 @@ +"""관 벽 최소 높이 거울 — 파이썬(`B06_Section_Engine_Culvert`) ↔ TS(`_Cross_Culvert_Const.ts`). + +⚠ 횡단도(TS)가 그리는 높이와 구조물 표·구조물도 그림(파이썬)이 같은 벽을 다르게 그리면 결함 + (2026-09-14 브레인 판정). 상수 셋이 같고 식이 같으면 같은 값. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B06_Section import B06_Section_Engine_Culvert as culvert # noqa: E402 + +TS = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Culvert_Const.ts").read_text(encoding="utf-8") +#: 세트 스펙 TS 짝(`common_util_culvert_sets.ts`)도 같은 상수를 들고 있음 — 셋을 함께 대조. +SETS = (ROOT / "common_util" / "common_util_culvert_sets.ts").read_text(encoding="utf-8") + + +def _ts_const(source: str, name: str) -> float: + return float(re.search(rf"export const {name} = ([\d.]+);", source).group(1)) + + +def test_상수_셋이_TS_와_같다() -> None: + for name in ("REVET_FREEBOARD_M", "REVET_HEIGHT_STEP_M", "REVET_EMBED_DEPTH_M"): + assert getattr(culvert, name) == _ts_const(TS, name) == _ts_const(SETS, name), name + + +def test_TS_식_모양이_그대로다() -> None: + """식을 바꾸면 여기서 잡힘 — 파이썬 식도 함께 고칠 것.""" + assert "const raw = diameterM + REVET_FREEBOARD_M;" in TS + assert "Math.ceil(raw / REVET_HEIGHT_STEP_M - 1e-9) * REVET_HEIGHT_STEP_M" in TS + assert "return revetTargetHeight(diameterM) + REVET_EMBED_DEPTH_M;" in TS + + +def test_관경별_최소_높이() -> None: + assert culvert.pipe_wall_min_height_m(1.0) == 2.0 + assert culvert.pipe_wall_min_height_m(0.8) == 1.8 + assert culvert.pipe_wall_min_height_m(1.2) == 2.2 diff --git a/resources/tester/test_b06_structure_walls_mirror.py b/resources/tester/test_b06_structure_walls_mirror.py index ca615abb..e8a2bf1d 100644 --- a/resources/tester/test_b06_structure_walls_mirror.py +++ b/resources/tester/test_b06_structure_walls_mirror.py @@ -88,6 +88,9 @@ def _python_result() -> dict: "tiers": options.get("tiers"), "lift_m": options.get("lift_m"), "shift_m": options.get("shift_m"), + # 전면 기울기 판정 칸(2026-09-14 B5) — 저장 원본 그대로. + "face_role": options.get("face_role"), + "face_slope_ratio": options.get("face_slope_ratio"), } ) # 얹기 규칙은 같은 함수를 쓸 수 없으므로(정본 파일을 읽는다) 여기서 같은 규칙으로 흉내낸다. @@ -144,8 +147,12 @@ def test_wall_specs_mirror(): py = _python_result() assert len(ts["specs"]) == len(py["specs"]) == 2 assert ts["specs"] == py["specs"] - ts_marks = [(s["chainage_m"], (s.get("revetment") or {}).get("type_id")) for s in ts["sections"]] - py_marks = [(s["chainage_m"], (s.get("revetment") or {}).get("type_id")) for s in py["sections"]] + ts_marks = [ + (s["chainage_m"], (s.get("revetment") or {}).get("type_id")) for s in ts["sections"] + ] + py_marks = [ + (s["chainage_m"], (s.get("revetment") or {}).get("type_id")) for s in py["sections"] + ] assert ts_marks == py_marks # 구간 밖 측점에는 안 붙는다 / 경계 오차(0.02m) 안은 붙는다. assert dict(ts_marks)[20.0] is None diff --git a/resources/tester/test_b06_wall_body_in_fill.py b/resources/tester/test_b06_wall_body_in_fill.py new file mode 100644 index 00000000..69370ff1 --- /dev/null +++ b/resources/tester/test_b06_wall_body_in_fill.py @@ -0,0 +1,73 @@ +"""벽 몸 중 **성토 폐회로 안에 든 넓이** — `wallBodyInFillM2` (2026-09-14 브레인 「사유로 드러낼 것」). + +폐회로 면적은 구조물 면적을 안 뺌(2026-09-06 사용자 확정)이라 벽 몸이 성토에도 들어감. +그 겹침을 재서 B08 줄 사유로 올림 — 고치지 않음. 실제 코드를 컴파일해 Node 로 돌림. +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" +MODULE = PROJECT_ROOT / "common_util" / "common_util_cross_structure_areas.ts" + +# 지반 0 · 트림 밖(offset 5~20)은 표고 1.0 수평 성토선 · 벽 몸 offset 10~11 · 표고 −0.5~1.5 +# ⇒ 성토 안(지반 0 ~ 선 1.0)에 든 몸 = 1 × 1 = 1.0㎡ · 지반 아래·선 위는 안 셈. +_RUNNER = """ +const { wallBodyInFillM2 } = require(process.argv[3]); +const { writeFileSync } = require("node:fs"); +const line = []; +for (let o = -20; o <= 20; o += 1) line.push({ offset: o, elevation: 0 }); +const input = { + designLine: line.map((p) => ({ offset_m: p.offset, elevation_m: 0 })), + ground: line, + trim: { minOffset: -5, maxOffset: 5, maxSlope: { points: [{ offset: 5, elevation: 1 }, { offset: 20, elevation: 1 }] } }, +}; +const body = [ + { offset: 10, elevation: -0.5 }, + { offset: 10, elevation: 1.5 }, + { offset: 11, elevation: 1.5 }, + { offset: 11, elevation: -0.5 }, +]; +const outside = body.map((p) => ({ offset: p.offset - 30, elevation: p.elevation })); +writeFileSync(process.argv[2], JSON.stringify({ inFill: wallBodyInFillM2(body, input), outside: wallBodyInFillM2(outside, input) })); +""" + + +@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치") +def test_벽_몸_중_성토_안_넓이만_잰다(tmp_path: Path) -> None: + out = tmp_path / "out" + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(TSC), + "--ignoreConfig", + "--target", + "es2022", + "--module", + "commonjs", + "--skipLibCheck", + "--outDir", + str(out), + str(MODULE), + ], + cwd=str(PROJECT_ROOT), + check=False, + capture_output=True, + ) + compiled = next(out.rglob("common_util_cross_structure_areas.js"), None) + assert compiled is not None, "tsc 실패" + (out / "runner.cjs").write_text(_RUNNER, encoding="utf-8") + result = tmp_path / "result.json" + subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(out / "runner.cjs"), str(result), str(compiled)], + cwd=str(PROJECT_ROOT), + check=True, + capture_output=True, + ) + produced = json.loads(result.read_text(encoding="utf-8")) + assert produced["inFill"] == pytest.approx(1.0, abs=1e-3) + assert produced["outside"] == pytest.approx(0.0, abs=1e-9) # 설계선이 지반 그대로인 자리 diff --git a/resources/tester/test_b06_wall_lean_table.py b/resources/tester/test_b06_wall_lean_table.py new file mode 100644 index 00000000..c33845e4 --- /dev/null +++ b/resources/tester/test_b06_wall_lean_table.py @@ -0,0 +1,142 @@ +# -*- coding: utf-8 -*- +"""B06 벽 전면 기울기도 **품셈 표준경사 표**를 읽음 — B5 (2026-09-14 브레인 판정 「양쪽 다 이 표를」). + +앞서 B06 횡단도는 벽(관 유입·유출 · 독립 기슭막이 · 다단 추가 · C군 벽)을 **1:0.3 붙박이**로 그렸고 +B08 은 품셈 13-4-4 [주]⑪ 표(원문 L7185~7191)로 셈 — 메쌓기 성토 H2.5 에서 1:0.3 ↔ 1:0.35 로 갈림. + +재는 것(브레인: 고치기 전 코드에서 빨강부터) + ① 네 갈래 — 직고 구간 × 성토/절토 × 메/찰 (+ 사용자 칸 `face_slope_ratio`·`face_role`, 못 가르면 종전 0.3) + TS 짝 `common_util_masonry_slope.ts` 을 실제로 돌려 파이썬 `face_slope_ratio` 와 한 칸씩 대조. + ② 기하가 그 값을 씀 — 벽 모양 파일에서 붙박이 `REVET_LEAN_RATIO` 곱이 사라지고 벽마다 기울기를 받음. +""" + +from __future__ import annotations + +import itertools +import json +import re +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_UnitQuantity_StoneSpec import ( # noqa: E402 + face_slope_ratio, + load_slope_table, +) +from common_util.common_util_structure_face_role import structure_face_role_of # noqa: E402 + +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" +TS_FILE = ROOT / "common_util" / "common_util_masonry_slope.ts" + +FORMS = ("돌쌓기(찰)", "돌쌓기(메)", "콘크리트") +HEIGHTS = (1.0, 1.5, 2.5, 3.0, 4.0, 6.0, 7.5) +MODES = ("left_cut", "right_cut", "both_cut", "both_fill", None) +SIDES = (None, "자동(성토 쪽)", "좌", "우", "양쪽") +OVERRIDES = ({}, {"face_role": "절토"}, {"face_slope_ratio": 0.5}) + +_RUNNER = """ +const { readFileSync, writeFileSync } = require("node:fs"); +const { wallLeanRatio } = require("./common_util_masonry_slope.js"); +const input = JSON.parse(readFileSync(process.argv[2], "utf8")); +const out = input.cases.map((c) => wallLeanRatio(input.table, c)); +writeFileSync(process.argv[3], JSON.stringify(out)); +""" + + +def _cases() -> list[dict]: + cases = [] + for form, height, mode, side, extra in itertools.product( + FORMS, HEIGHTS, MODES, SIDES, OVERRIDES + ): + cases.append( + {"form": form, "height_m": height, "section_mode": mode, "side": side, **extra} + ) + return cases + + +def _python(case: dict) -> float: + """B08 이 그 벽을 셀 때의 기울기 — 돌쌓기 형태만 표, 나머지는 종전 0.3.""" + wet = {"돌쌓기(찰)": True, "돌쌓기(메)": False}.get(case["form"]) + if wet is None: + return 0.3 + options = {k: case[k] for k in ("side", "face_role", "face_slope_ratio") if k in case} + face, reason = structure_face_role_of(case["section_mode"], options) + ratio, _basis = face_slope_ratio( + options, wet=wet, height_m=case["height_m"], face=face, face_reason=reason + ) + return ratio + + +@pytest.mark.skipif(shutil.which("node") is None or not TSC.is_file(), reason="node·tsc 없음") +def test_네_갈래_표를_TS_가_파이썬과_같게_읽는다(tmp_path: Path) -> None: + assert TS_FILE.is_file(), "TS 짝이 아직 없음 — B06 이 표를 안 읽는다" + out = tmp_path / "js" + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(TSC), + str(TS_FILE), + "--outDir", + str(out), + "--module", + "commonjs", + "--target", + "es2022", + "--ignoreConfig", + ], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + (out / "runner.cjs").write_text(_RUNNER, encoding="utf-8") + cases = _cases() + payload = tmp_path / "in.json" + result = tmp_path / "out.json" + payload.write_text( + json.dumps({"table": load_slope_table(), "cases": cases}, ensure_ascii=False), + encoding="utf-8", + ) + subprocess.run( # noqa: S603 + ["node", str(out / "runner.cjs"), str(payload), str(result)], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + got = json.loads(result.read_text(encoding="utf-8")) + wrong = [ + (case, ts, _python(case)) + for case, ts in zip(cases, got, strict=True) + if abs(ts - _python(case)) > 1e-9 + ] + assert not wrong, wrong[:5] + # 갈래가 실제로 갈렸는지 — 한 값만 나와 같아지는 것을 막음(메 성토 H2.5 0.35 · 찰 절토 H1.0 0.2) + assert {round(v, 2) for v in got} >= {0.2, 0.25, 0.3, 0.35, 0.4, 0.45, 0.5} + + +GEOMETRY = [ + "B06_Section_UI_Cross_Culvert_Geom.ts", + "B06_Section_UI_Cross_Wall.ts", + "B06_Section_UI_Cross_Culvert_Extra.ts", + "B06_Section_UI_Cross_Culvert_Solve.ts", +] + + +def test_횡단도가_최신_표준경사_판을_읽는다() -> None: + const = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Lean.ts").read_text(encoding="utf-8") + latest = sorted((ROOT / "resources" / "data_masonry").glob("masonry_slope_*.json"))[-1].name + imported = re.search(r'from "\.\./resources/data_masonry/(masonry_slope_[^"]+)"', const) + assert imported and imported.group(1) == latest + + +def test_벽_모양이_붙박이_기울기를_안_곱한다() -> None: + """벽 모양 파일마다 `REVET_LEAN_RATIO *` 곱이 남아 있으면 그 벽은 아직 1:0.3 붙박이.""" + for name in GEOMETRY: + source = (ROOT / "B06_Section" / name).read_text(encoding="utf-8") + assert not re.search(r"REVET_LEAN_RATIO\s*[*/]", source), name diff --git a/resources/tester/test_b07_standard_figure_reason.py b/resources/tester/test_b07_standard_figure_reason.py index 24815154..6ecb4ee4 100644 --- a/resources/tester/test_b07_standard_figure_reason.py +++ b/resources/tester/test_b07_standard_figure_reason.py @@ -19,15 +19,17 @@ ROOT = Path(__file__).resolve().parents[2] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardFigure import ( # noqa: E402 - FIGURE_TYPE_IDS, - SLOPE_TYPE_IDS, - figure_reason, -) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardSheet import ( # noqa: E402 build_standard_drawing, ) -from B08_Quantity.B08_Quantity_Engine_StructureSheet import sheet_title # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_StructureFigure import ( # noqa: E402 + FIGURE_TYPE_IDS, + figure_reason, +) +from B08_Quantity.B08_Quantity_Engine_StructureSheet import ( # noqa: E402 + SLOPE_TYPE_IDS, + sheet_title, +) def _sheet(type_id: str, **options) -> dict: @@ -43,9 +45,9 @@ def _sheet(type_id: str, **options) -> dict: } -def test_뒷길이가_없으면_무엇을_고르면_되는지_적는다() -> None: - reason = figure_reason(_sheet("masonry_wet")) - assert reason is not None and "뒷길이" in reason and "섭니다" in reason +def test_뒷길이가_없어도_표처럼_기본값으로_서니_까닭이_없다() -> None: + """2026-09-14 브레인 판정 — 표가 기본 45㎝ 로 서면 그림도 섬(까닭은 정말 못 그리는 장만).""" + assert figure_reason(_sheet("masonry_wet")) is None def test_뒷길이가_있으면_까닭이_없다() -> None: diff --git a/resources/tester/test_b07_standard_spec_blinding_weep.py b/resources/tester/test_b07_standard_spec_blinding_weep.py index 6eea04fb..df2d177b 100644 --- a/resources/tester/test_b07_standard_spec_blinding_weep.py +++ b/resources/tester/test_b07_standard_spec_blinding_weep.py @@ -21,7 +21,7 @@ ROOT = Path(__file__).resolve().parents[2] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardFigure import ( # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_StructureFigure import ( # noqa: E402 build_figure, ) from B08_Quantity.B08_Quantity_Engine_StructureSheet_Edit import ( # noqa: E402 @@ -83,10 +83,7 @@ def _figure_labels(**options) -> list[str]: "height_m": 2.0, "options": {"height_m": 2.0, "back_len_cm": 45, **options}, } - entities, _height = build_figure( - "d1", sheet, "layer", (0.0, 0.0), "#000000", "#111111", "#222222" - ) - return [str((item.get("shapeData") or {}).get("label") or "") for item in entities] + return [shape["text"] for shape in build_figure(sheet) or [] if shape["kind"] == "text"] def test_물빼기_주석이_구조물_값을_쓴다() -> None: diff --git a/resources/tester/test_b08_back_length_standard.py b/resources/tester/test_b08_back_length_standard.py new file mode 100644 index 00000000..7a26c8fe --- /dev/null +++ b/resources/tester/test_b08_back_length_standard.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +"""뒷길이 표준(품셈 13-4-4 [주]⑩) — 안 고른 돌쌓기의 뒷길이는 **표의 하한** (2026-09-14 브레인 판정 ㉮). + +앞서 붙박이 45㎝ 로 섰음. 표는 높이 × 메/찰 로 범위를 줌 — 하한이 규격(25·30·35·45·55·60·75)에 +없으면 **범위 안 그 위 첫 규격**(메쌓기 ~3m·~5m 하한 36 → 45). 경계값은 앞 칸(표준경사와 같음). +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_UnitQuantity_StoneSpec import ( # noqa: E402 + _back_length, + back_length_default_note, + standard_back_length, +) + +# (높이, 메쌓기 제안, 찰쌓기 제안) — 원문 L7174~7177 +CELLS = [ + (1.0, 25, 25), + (1.5, 25, 25), # 경계값은 앞 칸 + (2.5, 45, 30), # 메 36~45 → 45 · 찰 30~35 → 30 + (3.0, 45, 30), + (4.0, 45, 35), # 메 36~60 → 45 · 찰 35~45 → 35 + (6.0, 45, 35), # 메 45~75 · 찰 35~55 + (7.5, 75, 45), # 메 75이상 · 찰 45~60 +] + + +@pytest.mark.parametrize("height, dry, wet", CELLS) +def test_표_하한(height: float, dry: int, wet: int) -> None: + assert standard_back_length(wet=False, height_m=height)[0] == dry + assert standard_back_length(wet=True, height_m=height)[0] == wet + + +def test_하한이_규격에_없으면_그_위_첫_규격_사유() -> None: + cm, basis = standard_back_length(wet=False, height_m=2.5) + assert cm == 45 + assert "36~45㎝" in basis and "하한 36㎝ 는 규격 일곱(원문 L7152)에 없어" in basis + assert "원문 L7172~7181" in basis # 사용자가 되짚을 줄 번호(브레인 2026-09-14) + + +def test_고른_값이_이긴다() -> None: + assert _back_length({"back_len_cm": "55"}, wet=True, height_m=2.5) == 55 + assert back_length_default_note({"back_len_cm": 55}, 55, wet=True, height_m=2.5) is None + + +def test_메찰_높이를_모르는_자리는_종전_45() -> None: + assert _back_length({}) == 45 + assert "기본 45㎝" in (back_length_default_note({}, 45) or "") diff --git a/resources/tester/test_b08_build_table_templates.py b/resources/tester/test_b08_build_table_templates.py index adbbf5ae..de4976bb 100644 --- a/resources/tester/test_b08_build_table_templates.py +++ b/resources/tester/test_b08_build_table_templates.py @@ -38,6 +38,9 @@ def _structures() -> list[dict]: "length_m": 10, "foundation": "기초유", "stone_kind": "야면석·호박돌", + # 2026-09-14 — 안 고르면 뒷길이 표준 하한(찰 ≤3m 30㎝)이라 야면석 관측 무게(35·45·55)가 + # 없어 돌 줄이 안 섬. 돌 줄을 보는 시험이라 45 로 고름 · 빈 칸 길은 아래 시험. + "back_len_cm": 45, }, }, { @@ -76,6 +79,21 @@ def test_양식_갈음은_값을_안_움직인다() -> None: assert stone["source"] == "uljin_library" +def test_뒷길이_안_고르면_양식도_전개와_같은_표준_하한() -> None: + """품셈 13-4-4 [주]⑩ 하한(찰 ≤3m 30㎝)을 전개와 양식이 한 벌로 씀(브레인 판정 ㉮).""" + structures = _structures()[:1] + structures[0]["options"].pop("back_len_cm") + structures[0]["options"].pop("stone_kind") + names = {"masonry_wet": "돌쌓기(찰)"} + before = build_table(structures, names, None, None, 0.2, use_templates=False)["structures"][0] + after = build_table(structures, names, None, None, 0.2)["structures"][0] + assert after["library_item"] == "돌쌓기(찰)" + assert [c["name"] for c in after["components"]] == [c["name"] for c in before["components"]] + for got, want in zip(after["components"], before["components"]): + assert got["amount"] == pytest.approx(want["amount"], rel=1e-9, abs=1e-12), got["name"] + assert any("하한 30㎝" in note for note in before["notes"]) + + def test_자재총괄_갈래와_배합_성분() -> None: names = {"masonry_wet": "돌쌓기(찰)", "masonry_dry": "돌쌓기(메)"} before = build_material_table( diff --git a/resources/tester/test_b08_destination_no_default.py b/resources/tester/test_b08_destination_no_default.py new file mode 100644 index 00000000..67c4cd1c --- /dev/null +++ b/resources/tester/test_b08_destination_no_default.py @@ -0,0 +1,88 @@ +"""갈 곳(`destination`) 기본값 금지 — 명세 13장 「⛔ 기본값 금지(빠진 것이 조용해짐)」 (2026-09-14). + +종전 `DESTINATION.get(name, "quantity")` 는 표에 없는 이름을 **아무 축에도 안 가는 칸**으로 떨어뜨려 +L형수로 이형철근·면목 · 개거 유로폼·면목 · 떼흙막이 떼가 자재총괄에서 통째로 빠져 있었음. + +지키는 것 + ① 표에 없는 이름은 성분이 안 서고 **사유가 뜸**(조용히 새지 않음) + ② 정본 표(개거·떼흙막이·바닥막이)의 모든 줄 이름이 갈 곳 표에 있음 — 줄이 늘면 여기서 걸림 + ③ 드러난 넷이 제자리로 감(관측 원단위와 같은 갈 곳) +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Base import DESTINATION, routed # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import ( # noqa: E402 + BED_SILL_FORMS, + OPEN_DITCH_FORMS, + SOIL_GUARD_SOD, + open_ditch, + soil_guard, +) + + +def test_표에_없는_이름은_막히고_사유가_뜸() -> None: + notes: list[str] = [] + assert routed("낯선성분", notes) is None + assert routed("낯선성분", notes) is None + assert len(notes) == 1 and "갈 곳이 표" in notes[0] and "기본값 금지" in notes[0] + assert routed("터파기", notes) == "earthwork" and len(notes) == 1 + + +def test_정본_표의_줄_이름은_전부_갈_곳_표에_있음() -> None: + names = {row[0] for table in OPEN_DITCH_FORMS.values() for row in table["rows"]} + names |= {row[0] for row in SOIL_GUARD_SOD["rows"]} + names |= {table["stone_name"] for table in BED_SILL_FORMS.values()} + assert sorted(name for name in names if name not in DESTINATION) == [] + + +def test_드러난_넷이_제자리로_감() -> None: + ditch, notes = open_ditch(10.0, {"ditch_spec": "L형수로 H=0.2"}) + got = {component.name: component.destination for component in ditch} + assert got["이형철근"] == "material" and got["면목"] == "material" + assert next(c.spec for c in ditch if c.name == "이형철근") == "D13" # 이름·규격 따로 + assert not [note for note in notes if "갈 곳이 표" in note] + plain, _ = open_ditch(10.0, {}) + assert {c.name: c.destination for c in plain}["유로폼"] == "unit_price" + sod, sod_notes = soil_guard({"form": "떼"}) + assert {c.name: c.destination for c in sod}["떼"] == "material" + assert not [note for note in sod_notes if "갈 곳이 표" in note] + + +def test_관측_원단위_줄도_갈_곳이_없으면_막히고_사유() -> None: + """관측 원단위 로더의 `destination or "material"` 도 같은 병 — 줄이 늘 때 바로 드러나게.""" + from B08_Quantity.B08_Quantity_Engine_ObservedUnit import ObservedUnitTable, expand_observed + + table = ObservedUnitTable( + entries=[ + { + "type_id": "시험구조물", + "unit": "개소", + "components": [ + {"name": "콘크리트", "unit": "㎥", "amount": 1.0, "destination": "unit_price"}, + {"name": "새자재", "unit": "㎥", "amount": 2.0}, + ], + } + ] + ) + components, notes = expand_observed("시험구조물", {}, {"options": {}}, table) + assert [c["name"] for c in components] == ["콘크리트"] + assert any("새자재" in note and "기본값 금지" in note for note in notes) + + +def test_관측_원단위_표의_전_줄에_갈_곳이_있음() -> None: + from B08_Quantity.B08_Quantity_Engine_ObservedUnit import load_observed_table + + missing = [ + (entry.get("type_id"), item.get("name")) + for entry in load_observed_table().entries + for item in entry.get("components") or [] + if not item.get("destination") + ] + assert missing == [] diff --git a/resources/tester/test_b08_dry_height_face_role.py b/resources/tester/test_b08_dry_height_face_role.py new file mode 100644 index 00000000..465478e1 --- /dev/null +++ b/resources/tester/test_b08_dry_height_face_role.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +"""메쌓기 높이 사유(B2) · 성토/절토 칸(B3) — 2026-09-14 브레인 판정 ㉰·㉲. + +㉰ 두 기준 공존 — 임도기술교본 7-3:27 「메쌓기 2.0m 이하」 ↔ 품셈 13-4-4 [주]⑪ 원문 L7192 + 「3m 이상이면 전부 또는 하부를 찰쌓기」. 막지 않고 사유(B08 줄) · 칸 경고(B05, `warn_above`). +㉲ `face_role` — 비우면 단면유형 + 설치 측 자동 판정(계산이지 기본값이 아님) · 고르면 그 값이 이김. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import expand, stone_masonry # noqa: E402 +from common_util.common_util_structure_face_role import structure_face_role_of # noqa: E402 + +REGISTRY = json.loads( + (ROOT / "B05_Profile" / "B05_Profile_Structure_Types.json").read_text(encoding="utf-8") +) +FIELDS = (ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Fields.ts").read_text(encoding="utf-8") + + +def _options(type_id: str) -> dict[str, dict]: + item = next(t for t in REGISTRY["types"] if t["type_id"] == type_id) + return {o["key"]: o for o in item["options"]} + + +def _height_notes(height: float, wet: bool) -> list[str]: + _rows, notes = stone_masonry(height, 10.0, {"back_len_cm": 45}, wet) + return [n for n in notes if "메쌓기 높이" in n] + + +def test_메쌓기_높이_두_기준_사유() -> None: + assert _height_notes(2.0, False) == [] + over_guide = _height_notes(2.5, False) + assert len(over_guide) == 1 and "7-3:27" in over_guide[0] and "L7192" in over_guide[0] + over_limit = _height_notes(3.0, False) + assert len(over_limit) == 1 and "3m 이상이면" in over_limit[0] + assert _height_notes(3.5, True) == [] # 찰쌓기는 이 사유 대상 아님 + + +def test_등록부_메쌓기_높이_칸은_경고만() -> None: + height = _options("masonry_dry")["height_m"] + assert height["warn_above"] == 2.0 and "L7192" in height["warn_message"] + assert "is-outside-standard" in FIELDS and "warn_above" in FIELDS + + +def test_성토절토_칸이_이긴다() -> None: + assert structure_face_role_of("both_fill", {"face_role": "절토"}) == ( + "절토", + "사용자 지정 절토면(성토/절토 칸)", + ) + # 비우면 지금 판정 그대로 + assert structure_face_role_of("both_fill", {})[0] == "성토" + for type_id in ("masonry_wet", "masonry_dry", "revetment"): + role = _options(type_id)["face_role"] + assert role["choices"] == ["성토", "절토"] and role["default"] is None, type_id + + +def test_전개가_칸_값으로_기울기를_고른다() -> None: + structure = { + "structure_id": "w", + "type_id": "masonry_wet", + "start_m": 0.0, + "end_m": 10.0, + "options": {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45, "face_role": "절토"}, + } + result = expand(structure, None, None, "both_fill") + wall = next(c for c in result.components if c.name == "돌쌓기") + # 찰쌓기 · 절토 · 직고 ≤3m → 1:0.25 (성토였으면 1:0.30) + assert "절토" in wall.basis and "1:0.25" in wall.basis diff --git a/resources/tester/test_b08_extra_walls.py b/resources/tester/test_b08_extra_walls.py new file mode 100644 index 00000000..6cd5c457 --- /dev/null +++ b/resources/tester/test_b08_extra_walls.py @@ -0,0 +1,170 @@ +# -*- coding: utf-8 -*- +"""다단 추가 기슭막이도 수량이 섬 — ④ (2026-09-14 브레인 판정 「수량이 통째로 빠지는 것」). + +앞서 B06 횡단도는 다단 벽(`design.extra_wall_counts` · `revet_adjust.extra*` · `extra_spans`)을 +그리고 면적에도 넣었는데, B08 전개는 관 유입·유출 기준벽만 셈 → 다단 벽 돌쌓기·터파기가 한 줄도 안 섬. + +길 — 실제로 **선** 단 수·높이는 지형이 정하므로(요청보다 적게 설 수 있음) 서버 Node 재계산이 +관 연장처럼 「선 다단 벽 목록」(`design.extra_walls`)을 소유 측점에 남기고, B08 이 그 목록으로 줄을 세움. +값이 없는 다단(높이를 안 적어 기본 1.5m 로 지반에 맞춰 그린 단)은 **미확정·금액 밖** 규칙 그대로. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_Pipe import ( # noqa: E402 + extra_walls_from_designs, + facility_structures, +) +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402 + +LAYOUTS = (ROOT / "B06_Section" / "B06_Section_Structure_Layouts.ts").read_text(encoding="utf-8") +NODE = (ROOT / "B06_Section" / "B06_Section_Server_Calc_Node.ts").read_text(encoding="utf-8") +PREBUILD = (ROOT / "B06_Section" / "B06_Section_Server_Calc_Prebuild.py").read_text( + encoding="utf-8" +) + +WALLS = [ + { + "key": "extra0", + "side": "outlet", + "form": "돌쌓기(메)", + "height_m": 1.8, + "height_set": True, + "form_set": True, + "before_m": 4.0, + "after_m": 4.0, + }, + { + "key": "extra1", + "side": "outlet", + "form": "돌쌓기(메)", + "height_m": 1.3, + "height_set": False, + "form_set": False, + "before_m": 5.0, + "after_m": 5.0, + }, +] +POINT = { + "chainage_m": 85.05, + "options": { + "pipe_diameter_mm": 1000, + "outlet_revet_form": "돌쌓기(메)", + "outlet_revet_height_m": 2.0, + "outlet_revet_length_m": 10, + "inlet_revet_form": "돌쌓기(찰)", + "inlet_revet_height_m": 2.0, + "inlet_revet_length_m": 10, + "revet_foundation": "기초유", + }, +} + + +def _tiers(rows: list[dict]) -> list[dict]: + return [row for row in rows if "다단" in str(row.get("attachment_label") or "")] + + +def test_설계에서_다단_목록을_측점별로_읽는다() -> None: + designs = [ + {"chainage_m": 85.0, "design": {"extra_walls": WALLS}}, + {"chainage_m": 140.0, "design": {"cut_area_m2": 1.0}}, + ] + assert extra_walls_from_designs(designs) == {85.0: WALLS} + + +def test_선_다단_벽마다_기슭막이_줄이_선다() -> None: + rows = facility_structures([POINT], {85.0: WALLS}) + tiers = _tiers(rows) + assert [row["attachment_label"] for row in tiers] == [ + "유출측 다단 기슭막이 1단", + "유출측 다단 기슭막이 2단", + ] + first, second = tiers + assert first["type_id"] == "revetment" + assert first["options"]["height_m"] == 1.8 and first["options"]["form"] == "돌쌓기(메)" + assert (first["start_m"], first["end_m"]) == (85.05 - 4.0, 85.05 + 4.0) + assert first["options"]["foundation"] == "기초유" # 기준벽과 같은 기초 + assert first["unconfirmed"] == "" + # 높이를 안 적은 단 — 줄은 서되 미확정 + assert "다단 높이를 안 적음" in second["unconfirmed"] + + +def test_다단_줄이_원단위와_금액_합에_든다() -> None: + table = build_table(facility_structures([POINT], {85.0: WALLS}), {"revetment": "기슭막이"}, {}) + tiers = [s for s in table["structures"] if "다단" in s["name"]] + assert len(tiers) == 2 + assert tiers[0]["components"] and not tiers[0]["unconfirmed"] + assert tiers[1]["unconfirmed"] + + +def test_윗단이_미확정이면_아랫단도_미확정() -> None: + """면적은 첫 미확정 단부터 아래를 없는 것으로 봄(B06) — B08 도 같은 선에서 끊어야 성토·벽이 함께 움직임.""" + walls = [ + dict(WALLS[1], key="extra0"), + dict(WALLS[0], key="extra1"), + ] # 1단 미확정 · 2단 높이 적음 + tiers = _tiers(facility_structures([POINT], {85.0: walls})) + assert "다단 높이를 안 적음" in tiers[0]["unconfirmed"] + assert "윗단(1단)이 미확정" in tiers[1]["unconfirmed"] + + +def test_미확정_단은_성토를_안_깎는다() -> None: + """2026-09-14 브레인 판정 — 미확정 벽이 성토만 깎으면 「벽도 성토도 없는 빈 자리」가 생김.""" + geom = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Culvert_Geom.ts").read_text( + encoding="utf-8" + ) + assert "extendTrimSlope(drawnFillPoints(extrasForArea)" in geom + assert "extendTrimSlope(drawnFillPoints(basinExtrasForArea)" in geom + assert "applied.h == null" in geom + + +def test_벽_몸이_성토에도_든_겹침을_사유로_드러냄() -> None: + """2026-09-14 브레인 — 폐회로는 구조물 면적을 안 뺌(2026-09-06 확정)이라 벽 몸이 성토에도 셈. + 고치지 않고 사유로: 258.12 실측 1단 벽 몸 1.335㎡ 가 성토 폐회로 안.""" + walls = [dict(WALLS[0], fill_overlap_m2=1.336, before_m=5.0, after_m=5.0)] + note = " ".join(_tiers(facility_structures([POINT], {85.0: walls}))[0]["notes"]) + assert "성토 면적에도" in note and "1.34㎡" in note and "13.4㎥" in note + bare = " ".join(_tiers(facility_structures([POINT], {85.0: WALLS[:1]}))[0]["notes"]) + assert "성토 면적에도" not in bare # 값이 없으면(옛 저장본) 지어내지 않음 + + +def test_서버가_벽_몸_겹침을_잰다() -> None: + assert "wallBodyInFillM2(" in LAYOUTS and "fill_overlap_m2" in LAYOUTS + + +def test_관_기준벽도_겹침_사유가_뜬다() -> None: + """2026-09-14 브레인 ㉡ — 같은 겹침이 관 기준벽에도 있음. 다단에만 뜨면 「다단만 문제」로 읽힘. + 258.12 실측 유출 벽 몸 1.417㎡ 가 성토 폐회로 안.""" + designs = [ + {"chainage_m": 85.0, "design": {"extra_walls": [], "wall_fill_overlap": {"outlet": 1.417}}} + ] + rows = facility_structures([POINT], extra_walls_from_designs(designs)) + by_label = { + row["attachment_label"]: " ".join(row["notes"]) + for row in rows + if row.get("notes") is not None + } + outlet = next(text for label, text in by_label.items() if "유출" in label) + inlet = next(text for label, text in by_label.items() if "유입" in label) + assert "성토 면적에도" in outlet and "1.42㎡" in outlet and "14.2㎥" in outlet + assert "성토 면적에도" not in inlet # 잰 값이 없는 벽엔 지어내지 않음 + assert not _tiers(rows) # 기준벽 값이 다단 줄로 새지 않음 + assert "wall_fill_overlap" in LAYOUTS and '"wall_fill_overlap"' in PREBUILD + + +def test_목록이_없으면_종전과_같다() -> None: + assert not _tiers(facility_structures([POINT])) + assert not _tiers(facility_structures([POINT], {})) + + +def test_서버_재계산이_선_다단_목록을_남긴다() -> None: + assert "export function extraWallRows" in LAYOUTS + assert "extraWallRows(sections)" in NODE and "extra_walls" in NODE + assert '"extra_walls"' in PREBUILD diff --git a/resources/tester/test_b08_face_dressing.py b/resources/tester/test_b08_face_dressing.py new file mode 100644 index 00000000..ca4d9394 --- /dev/null +++ b/resources/tester/test_b08_face_dressing.py @@ -0,0 +1,214 @@ +"""면고르기 줄 — 2026-09-14 브레인 판정 Ⓐ~Ⓓ. + +종전: 면적(face_dressing 성·절토)은 있는데 토공집계가 줄을 안 세워 인계가 안 보냄(내역 0줄). +실무 대조: 오솔길 BOM 6벌 면적 셈법 ±0.11% 일치 · 그러나 내역엔 사면적 통째가 아님 +(거창 줄 없음 · 영월 성토면 × 50%) — 원문 9-19-1 성토면 [주] 「식재를 위한」 · 9-19-2·3 [주]① +「식재기반 조성에만」 → Ⓐ 밑수 = 초류종자살포(파종) 면적 · 덮어쓰기 칸 · 파종 0 이면 0 + 사유 +Ⓑ 절토면 토사/암 갈라 암은 시공법(리핑 9-19-2 · 발파 9-19-3) Ⓒ 토질·시공 칸 둘 · 제안값 없음 +Ⓓ 성토면 기계(굴착기 0.6㎥ 형식 둘)는 사유로. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from test_b08_slope_area import 절토_설계선 # noqa: E402 + +from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import ( # noqa: E402 + FACE_DRESSING_CUT_CLASSES, + FACE_DRESSING_FILL_CLASSES, + SummaryInput, + build_rows, + build_table, +) +from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_SlopeArea import build_table as slope_table # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_SlopeLength import ( # noqa: E402 + StationSlope, + station_slope, +) + +CUT_SAND = "절토면 · 모래ㆍ사질토ㆍ점토ㆍ점질토" +FILL_CLAY = "성토면 · 인력 · 점토 또는 점질토" + + +# ── 사면길이 · 면적 — 절토면을 토사/암으로 ───────────────────────────── + + +def test_절토_사면길이가_토사_암으로_갈림() -> None: + slope = station_slope(20.0, 절토_설계선()) + assert slope.cut_soil_length_m + slope.cut_rock_length_m == pytest.approx(slope.cut_length_m) + soil = sum(s.length_m for s in slope.segments if s.role == "cut" and s.material == "soil") + assert slope.cut_soil_length_m == pytest.approx(soil) and soil > 0 + assert slope.cut_rock_length_m > 0 + + +def test_2단이_아닌_측점은_설계_지반_프리셋으로_가르고_모르면_안_가름() -> None: + """토사 프리셋은 비탈 전체가 토사 · 암 프리셋 1단은 전체가 암 · 프리셋이 없으면 못 가름.""" + one = {**절토_설계선(), "two_stage_slope": False} + soil = station_slope(20.0, {**one, "geometry_preset": "soil"}) + assert ( + soil.cut_soil_length_m == pytest.approx(soil.cut_length_m) and soil.cut_rock_length_m == 0 + ) + rock = station_slope(20.0, {**one, "geometry_preset": "rock"}) + assert ( + rock.cut_rock_length_m == pytest.approx(rock.cut_length_m) and rock.cut_soil_length_m == 0 + ) + unknown = station_slope(20.0, one) + assert unknown.cut_length_m > 0 and unknown.cut_soil_length_m == unknown.cut_rock_length_m == 0 + + +def test_면적_합계에_절토면_토사_암이_평균단면적법으로_실림() -> None: + one = StationSlope( + chainage_m=0.0, cut_length_m=4.0, cut_soil_length_m=3.0, cut_rock_length_m=1.0 + ) + two = StationSlope( + chainage_m=20, cut_length_m=6.0, cut_soil_length_m=2.0, cut_rock_length_m=4.0 + ) + slopes = [one, two] + totals = slope_table(slopes)["totals"] + assert totals["face_dressing_cut"] == pytest.approx(100.0) + assert totals["face_dressing_cut_soil"] == pytest.approx(50.0) + assert totals["face_dressing_cut_rock"] == pytest.approx(50.0) + + +# ── 토공집계 — 밑수 = 파종 면적 ────────────────────────────────────── + + +def _source(**extra) -> SummaryInput: + base = dict( + slope_totals={ + "face_dressing_fill": 1000.0, + "face_dressing_cut": 500.0, + "face_dressing_cut_soil": 300.0, + "face_dressing_cut_rock": 200.0, + }, + rock_classes=["토사", "연암", "경암"], + rock_ratios_pct={"연암": 50.0, "경암": 50.0}, + application_ratios={"seed_spray_fill": 0.5, "seed_spray_cut": 1.0}, + ) + base.update(extra) + return SummaryInput(**base) + + +def _faces(source: SummaryInput) -> list[tuple[str, str, float, bool]]: + return [ + (row.spec, row.item, round(row.amount, 6), row.in_bill) + for row in build_rows(source) + if row.group == "면고르기" + ] + + +def test_면고르기는_파종_면적을_따라가고_절토면은_토사_암_갈래로() -> None: + """성토 1000 × 파종 50% = 500 · 절토 500 × 100% → 토사 300 · 암 200 을 구성비로 연암·경암.""" + assert _faces(_source()) == [ + ("성토면", "", 500.0, True), + ("절토면", "토사", 300.0, True), + ("절토면 · 연암", "연암", 100.0, True), + ("절토면 · 경암", "경암", 100.0, True), + ] + fill = next(r for r in build_table(_source())["rows"] if r["spec"] == "성토면") + assert fill["amount_gross"] == 1000.0 and fill["application_ratio_pct"] == 50.0 + assert "초류종자살포" in fill["note"] + + +def test_덮어쓴_면적이_이기고_비고에_파종_면적과_나란히() -> None: + source = _source(face_dressing_area_m2={"fill": 123.0, "cut": 100.0}) + faces = _faces(source) + assert faces[0] == ("성토면", "", 123.0, True) + assert faces[1] == ("절토면", "토사", 60.0, True) # 100 × 300/500 + fill = next(r for r in build_rows(source) if r.group == "면고르기") + assert "123" in fill.note and "500" in fill.note + + +def test_파종_면적이_0이면_면고르기도_0이고_내역에_안_섬() -> None: + source = _source(application_ratios={"seed_spray_fill": 0.0, "seed_spray_cut": 0.0}) + faces = _faces(source) + assert faces[0] == ("성토면", "", 0.0, False) + rows = [r for r in build_rows(source) if r.group == "면고르기"] + assert all("파종 면적이 0" in r.note for r in rows) + + +# ── 인계 — 코드 · 갈래 · 칸 비면 사유 ───────────────────────────────── + + +def _handoff(**extra) -> dict: + summary = build_table(_source()) + return build_handoff( + summary_table=summary, + ground_classes=["토사", "연암", "경암"], + ground_methods={"연암": "ripping", "경암": "blasting"}, + **extra, + ) + + +def _items(handoff: dict) -> list[dict]: + return [row for row in handoff["work_items"] if row["name"] == "면고르기"] + + +def test_면고르기_인계는_성토면_절토면_토사_암을_각_절로() -> None: + items = _items(_handoff(face_dressing_cut_class=CUT_SAND, face_dressing_fill_class=FILL_CLAY)) + got = [(r["spec"], r["ground_class"], r["work_item_code"], r["variant_value"]) for r in items] + assert got == [ + ("성토면", None, "FP-09-19-01", FILL_CLAY), + ("절토면", "토사", "FP-09-19-01", CUT_SAND), + ("절토면 · 연암", "연암", "FP-09-19-02", None), + ("절토면 · 경암", "경암", "FP-09-19-03", None), + ] + assert not any(r["blocked_kind"] for r in items) + + +def test_토질_시공_칸이_비면_금액_없이_입력_사유() -> None: + items = _items(_handoff()) + assert [r["spec"] for r in items[:2]] == ["성토면", "절토면"] # 줄이 없으면 헛통과 막음 + for row in items[:2]: + assert row["blocked_kind"] == "input_missing", row + assert "산출 조건" in row["blocked_reason"] + + +def test_고르는_갈래는_원문_표_두_벌과_마스터_갈래_키가_같음() -> None: + """B08 칸의 선택지와 B09 표 읽기가 세운 갈래 — 두 벌이 갈리면 빨강.""" + from B09_Estimation.B09_Estimation_ResourceAxis import load_work_item_master + from B09_Estimation.B09_Estimation_UnitPrice import normalize_variant_key + + node = next( + n for n in load_work_item_master()["work_items"] if n["work_item_code"] == "FP-09-19-01" + ) + keys = {normalize_variant_key(k) for k in node["variant_keys"]} + choices = { + normalize_variant_key(k) for k in FACE_DRESSING_CUT_CLASSES + FACE_DRESSING_FILL_CLASSES + } + assert keys == choices + + +# ── 내역 — 갈래가 서면 금액 · 기계는 사유 ───────────────────────────── + + +def test_내역은_갈래가_서면_금액이_서고_성토면_기계는_무한궤도_제안() -> None: + """9-19-2(연암 · 리핑) · 9-19-1(성토면 인력 점토 500㎡ × 3,269원) 금액이 섬. + ⓘ 9-19-1 은 첫 커밋(ee9368ec) 때 공기압축기 3.5 손료 미확보(공종 단위 「일부만」)에 막혀 있었고 + 운전경비 표 괄호 딸림값 파서 고침으로 풀림(같은 날 · `test_b09_operating_paren_spec`). + 성토면·기계는 굴착기 형식 두 갈래 · 제안 무한궤도(영월 실무 「06M3 B/H」 · 브레인 ②).""" + from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill + + handoff = _handoff(face_dressing_cut_class=CUT_SAND, face_dressing_fill_class=FILL_CLAY) + rows = [r for r in build_bill(handoff).rows if str(r.code).startswith("FP-09-19-0")] + ripping = next(r for r in rows if r.code == "FP-09-19-02") + assert ripping.amount_krw and int(ripping.amount_krw) > 0, ripping.note + fill = next(r for r in rows if r.code == "FP-09-19-01" and r.spec == FILL_CLAY) + assert fill.amount_krw == 500 * 3269, fill.note + track = "성토면 · 기계 · 무한궤도" + machine = _handoff(face_dressing_cut_class=CUT_SAND, face_dressing_fill_class=track) + row = next(r for r in build_bill(machine).rows if r.code == "FP-09-19-01" and r.spec == track) + assert row.amount_krw and int(row.amount_krw) > 0, row.note + from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import FACE_DRESSING_FILL_SUGGESTED + + value, basis = FACE_DRESSING_FILL_SUGGESTED + assert value == track and "06M3 B/H" in basis and "타이어" in basis diff --git a/resources/tester/test_b08_face_dressing_fields_ui.py b/resources/tester/test_b08_face_dressing_fields_ui.py new file mode 100644 index 00000000..9a7e8e71 --- /dev/null +++ b/resources/tester/test_b08_face_dressing_fields_ui.py @@ -0,0 +1,32 @@ +"""면고르기 산출 조건 칸 넷 — 화면이 서버 선택지를 그대로 쓰고 [저장]에 네 키를 싣는지(2026-09-14 메인 부탁). + +서버(ee9368ec)는 `face_dressing_cut_class`·`face_dressing_fill_class`·`face_dressing_fill_area_m2`· +`face_dressing_cut_area_m2` 를 받고 선택지는 `earthwork-table` 의 `face_dressing_choices` 로 내려줌. +⚠ 선택지를 화면에 다시 적으면 두 벌이 됨 — TS 에 갈래 이름이 없어야 함. +""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +PAGE = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Page.ts").read_text(encoding="utf-8") +SIDE = ROOT / "B08_Quantity" / "B08_Quantity_UI_Side_FaceDressing.ts" +KEYS = ( + "face_dressing_cut_class", + "face_dressing_fill_class", + "face_dressing_fill_area_m2", + "face_dressing_cut_area_m2", +) + + +def test_저장에_네_키를_싣고_불러온다() -> None: + for key in KEYS: + assert f"{key}: draft.{key}" in PAGE, f"[저장]에 {key} 없음" + assert f"{key}: (stored.{key}" in PAGE, f"불러오기에 {key} 없음" + + +def test_선택지는_서버_목록_그대로() -> None: + side = SIDE.read_text(encoding="utf-8") + assert "appendFaceDressingFields(" in PAGE and "face_dressing_choices" in PAGE + assert "choices.cut" in side and "choices.fill" in side + for name in ("모래ㆍ사질토", "성토면 · 인력", "성토면 · 기계"): + assert name not in side and name not in PAGE, f"갈래 이름 {name} 을 화면에 다시 적음" diff --git a/resources/tester/test_b08_facility_structures.py b/resources/tester/test_b08_facility_structures.py new file mode 100644 index 00000000..d0cf477c --- /dev/null +++ b/resources/tester/test_b08_facility_structures.py @@ -0,0 +1,130 @@ +"""계곡 통과 시설(`pipe_points.json`)이 원단위 전개·인계에 선다 (A1, 2026-09-14). + +⚠ 앞서 전개가 `structures.json` 만 읽어 관 유입·유출부 기슭막이·집수정·독립 기슭막이가 + 원단위·내역에 한 줄도 안 섰음. 관 줄(품셈 12-11 m당)에는 기슭막이 몫이 없어 **한 번만** 셈. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B05_Profile.B05_Profile_Structures_Schema import StructureInstance # noqa: E402 +from B08_Quantity import B08_Quantity_Router_Material as material # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_Pipe import build_rows, facility_structures # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402 + +NAMES = {"pipe": "배수관", "revetment": "기슭막이", "ford_bridge": "세월교"} + + +def _table(points: list[dict]) -> dict: + return build_table(facility_structures(points), NAMES) + + +def test_관_하나에_기슭막이_둘이_기본값_사유와_함께_서고_관_줄에는_없다() -> None: + point = {"chainage_m": 100.0, "options": {"pipe_diameter_mm": 1000}} + table = _table([point]) + names = [s["name"] for s in table["structures"]] + assert names == ["배수관 · 유입부 기슭막이", "배수관 · 유출부 기슭막이"] + inlet, outlet = table["structures"] + assert inlet["options"]["form"] == "돌쌓기(찰)" and outlet["options"]["form"] == "돌쌓기(메)" + assert all(s["components"] and s["length_m"] == 10 for s in table["structures"]) + assert "기본값으로 섰음" in inlet["notes"][0] + # 높이는 횡단도와 같은 관경 기준 최소 높이(Ø1000 → 2.0) — 그림 둘·표가 한 값(브레인 판정) + assert inlet["height_m"] == outlet["height_m"] == 2.0 and "관경 기준" in inlet["notes"][0] + # 관 줄은 관 연장만 — 기슭막이가 거기 섞이지 않음(두 번 안 셈). + pipe = build_rows([point], [{"chainage_m": 100.0, "design": {"pipe_length_m": 8}}]) + assert [row["quantity"] for row in pipe["rows"]] == [8.0] + + work = {row["name"]: row for row in build_handoff(unit_quantity_table=table)["work_items"]} + assert work["배수관 · 유입부 기슭막이"]["work_item_code"] == "FP-13-04-05" + assert work["배수관 · 유출부 기슭막이"]["work_item_code"] == "FP-13-04-02" + assert work["배수관 · 유입부 기슭막이"]["unit"] == "㎡" + + +def test_기본값으로_선_벽은_줄만_서고_금액_합에는_안_든다() -> None: + """브레인 판정(2026-09-14) — 줄은 서야 채울 자리가 보이고, 금액은 실제 값이 있을 때만.""" + point = {"chainage_m": 100.0, "options": {"pipe_diameter_mm": 1000}} + table = _table([point]) + assert all(s["unconfirmed"] and s["components"] for s in table["structures"]) + assert table["totals"] == [] # 원단위 합(자재·채집석 밑수)에도 안 듦 + handoff = build_handoff(unit_quantity_table=table) + rows = handoff["work_items"] + walls = [r for r in rows if "기슭막이" in r["name"]] + assert len(walls) == 2 and all( + not r["in_bill"] and r["blocked_kind"] == "unconfirmed" for r in walls + ) + assert walls[0]["quantity"] > 0 # 수량은 보임 + # 터파기·되메우기·기초잡석·버림 타설이 금액 줄로 번지지 않음 + assert not [r for r in rows if r["in_bill"] and r["quantity"] > 0] + + from B09_Estimation.B09_Estimation_BillOfQuantities import bill_summary, build_bill + + bill = build_bill(handoff) + placed = [r for r in bill.rows if not r.is_group and "기슭막이" in r.name] + assert len(placed) == 2 and all(r.unconfirmed == 1 and r.amount_krw is None for r in placed) + summary = bill_summary(bill) + assert summary["unpriced_count"] == 2 and summary["body_total_krw"] == "0" + + # 치수를 다 적으면 미확정이 풀림 + filled = { + f"{side}_revet_{key}": value + for side in ("inlet", "outlet") + for key, value in (("form", "돌쌓기(메)"), ("height_m", 1.5), ("length_m", 6)) + } + confirmed = _table([{**point, "options": {**point["options"], **filled}}]) + assert not any(s["unconfirmed"] for s in confirmed["structures"]) + + +def test_유입구가_집수정이면_집수정_줄이_사유로_서고_유입_기슭막이는_없다() -> None: + table = _table([{"chainage_m": 5.0, "options": {"inlet_type": "집수정"}}]) + basin, outlet = table["structures"] + assert basin["type_id"] == "pipe_inlet_basin" and not basin["components"] and basin["notes"] + assert outlet["name"] == "배수관 · 유출부 기슭막이" + + +def test_유입구가_기슭막이인데_집수정_형식이_남아_있으면_집수정은_안_세고_알린다() -> None: + options = {"inlet_type": "기슭막이", "inlet_basin_form": "□형(기본형)"} + inlet = _table([{"chainage_m": 5.0, "options": options}])["structures"][0] + assert inlet["type_id"] == "revetment" and "집수정은 안 셈" in inlet["notes"][0] + + +def test_독립_기슭막이_한쪽이면_두_칸이_같을_때만_세고_다르면_사유() -> None: + same = {"facility": "revetment", "chainage_m": 50.0, "options": {"side": "좌", "height_m": 2.0}} + rows = _table([same])["structures"] + assert [r["name"] for r in rows] == ["기슭막이 · 좌 벽"] and rows[0]["components"] + differ = {**same, "options": {"side": "우", "inlet_revet_height_m": 3.0}} + row = _table([differ])["structures"][0] + assert not row["components"] and "서버가 못 가림" in row["notes"][-1] + + +def test_독립_기슭막이_높이는_설계자_입력_없으면_미확정과_사방_근거() -> None: + """2026-09-14 브레인 판정 — 높이 = 계획홍수위 + 0.5~0.7m(사방기술교본 2-나:141 · 3-가:181).""" + point = {"facility": "revetment", "chainage_m": 50.0, "options": {"side": "좌"}} + (row,) = _table([point])["structures"] + assert "높이를 안 적음" in row["unconfirmed"] and "2-나:141" in row["unconfirmed"] + assert any("사방(계류) 교본 기준" in note and "3-가:151" in note for note in row["notes"]) + with_height = {**point, "options": {"side": "좌", "height_m": 2.0, "form": "돌쌓기(메)"}} + (row,) = _table([with_height])["structures"] + assert "높이를 안 적음" not in row["unconfirmed"] + + +def test_산출식_없는_세월교는_사유로_서고_터파기_빠짐_줄을_안_만든다() -> None: + table = _table([{"facility": "ford_bridge", "chainage_m": 30.0, "options": {}}]) + assert "산출식이 아직 없습니다" in table["structures"][0]["notes"][0] + rows = build_handoff(unit_quantity_table=table)["work_items"] + assert not any("터파기가 안 선 구조물" in str(row.get("spec_detail")) for row in rows) + + +def test_구조물_목록의_관_지점_종류_옛_저장분은_안_셈(monkeypatch, tmp_path) -> None: + old = StructureInstance( + structure_id="old", type_id="revetment", placement="point", chainage_m=10.0 + ) + monkeypatch.setattr(material, "load_structures", lambda root: (1, [old])) + targets, _names, skipped = material._collect_structures(str(tmp_path)) + assert targets == [] and any("관 지점 정본이 주인" in note for note in skipped) diff --git a/resources/tester/test_b08_formwork.py b/resources/tester/test_b08_formwork.py index 14a5879f..52d7cb82 100644 --- a/resources/tester/test_b08_formwork.py +++ b/resources/tester/test_b08_formwork.py @@ -51,7 +51,8 @@ def test_횟수별_비율을_여기서_곱하지_않을것() -> None: euroform = next( c for s in table["structures"] for c in s["components"] if c["name"] == "유로폼" ) - assert euroform["amount"] == 32.0 # 3.20 ㎡/m × 10m — 46.1 % 를 곱하지 않았다 + # 3.205 ㎡/m × 10m — 46.1 % 를 곱하지 않았다(2026-09-14 원문 값 3.205 · 전 라이브러리 3.20 → 32.0) + assert abs(euroform["amount"] - 32.05) < 1e-9 # 비율표는 데이터에 있되 값에 안 걸린다. assert load_formwork_table().reuse_ratio_pct["plywood"]["3"] == 46.1 diff --git a/resources/tester/test_b08_frame_material.py b/resources/tester/test_b08_frame_material.py index 3f2c85aa..b28fe237 100644 --- a/resources/tester/test_b08_frame_material.py +++ b/resources/tester/test_b08_frame_material.py @@ -33,7 +33,22 @@ from B08_Quantity.B08_Quantity_Engine_Preparation import ( # noqa: E402 def 이름별(rows: list[dict]) -> dict: - return {(row["source"], row["name"]): row for row in rows} + return {(row["source"], f"{row['name']} {row.get('spec', '')}".strip()): row for row in rows} + + +def test_이름과_규격을_가른다() -> None: + """이름 칸에 규격을 섞으면 할증률표(각재 5 · 판재 10)와 안 맞음 — 관급구분 키는 그대로.""" + from B08_Quantity.B08_Quantity_Engine_MaterialSummary import build_table + + rows = frame_material_rows(규준틀) + assert {(row["name"], row["spec"]) for row in rows} == { + ("각재", "50×50"), + ("판재", "T12"), + ("못", ""), + } + table = build_table({"structures": []}, extra_materials=rows) + rates = {row["supply_key"]: row["surcharge_pct"] for row in table["rows"]} + assert rates == {"각재 50×50": 5, "판재 T12": 10, "못": None} def test_개소가_서면_재료도_선다() -> None: @@ -70,6 +85,28 @@ def test_자재_축으로_간다() -> None: assert all(row["destination"] == "material" for row in frame_material_rows(규준틀)) +def test_화면과_인계가_같은_한_벌을_부른다() -> None: + """자재총괄 화면(6줄)·인계(9줄)가 갈리던 자리 — 규준틀 재료를 한 곳에서 얹음(2026-09-14). + + ⚠ 두 라우터가 이 함수를 부르는지까지 본다 — 한쪽이 옛 길로 돌아가면 다시 갈림. + """ + import inspect + + from B08_Quantity import B08_Quantity_Router_Material as router + + table = router.material_table_for( + {"structures": []}, + {"material_supply": {"각재 50×50": "owner_supplied"}}, + {"rows": 규준틀}, + ) + keys = {row["supply_key"]: row for row in table["rows"]} + assert set(keys) == {"각재 50×50", "판재 T12", "못"} + assert keys["각재 50×50"]["supply"] == "owner_supplied" # 화면에서 고른 값이 먹음 + for route in (router.get_material_summary, router.get_handoff): + source = inspect.getsource(route) + assert "material_table_for(" in source and "build_material_table(" not in source + + def test_제안값이_실무_관측값과_같다() -> None: """울진 소광 §8 — 각재 50×50 0.0044㎥ · 판재 T12 0.0029㎥ · 못 0.03㎏(개소당).""" assert FRAME_MATERIAL_SUGGESTED["각재 50×50"][0] == 0.0044 diff --git a/resources/tester/test_b08_handoff.py b/resources/tester/test_b08_handoff.py index 173643d5..201dc864 100644 --- a/resources/tester/test_b08_handoff.py +++ b/resources/tester/test_b08_handoff.py @@ -188,6 +188,11 @@ def test_무대는_넘기되_내역에는_안_섬() -> None: assert dump["in_bill"] is True assert dump["work_item_code"] == "FP-10-12" assert dump["haul_distance_m"] == pytest.approx(1200.0) + # 2026-09-14 — 덤프 운반마다 「덤프 적재」 짝 줄(10-12 「1. 적재」) · 거리 없음 · 수량 같음 + loading = 줄(handoff, "덤프 적재") + assert loading["haul_equipment"] == "dump_loading" and loading["haul_distance_m"] is None + assert loading["quantity"] == dump["quantity"] and loading["variant_value"] == "토사" + assert not any(r["name"] == "덤프 적재" and r["spec"] != "토사" for r in handoff["work_items"]) def test_합계_줄은_내역에_안_섬() -> None: @@ -367,6 +372,20 @@ def test_시공법을_안_정하면_찍지_않고_드러냄() -> None: assert any("시공법" in item for item in handoff["unmatched_work_items"]) +def test_갈래로_안_나뉜_암은_구성비가_빠졌다고_말함() -> None: + """2026-09-14 — 한 줄 「암」은 시공법만 골라서는 안 풀림. B09 내역도 이 까닭을 그대로 실음.""" + handoff = build_handoff(summary_table=집계표(집계줄("흙깎기", "암", 100.0))) + row = handoff["work_items"][0] + assert row["work_item_code"] is None and row["blocked_kind"] == "input_missing" + assert "암 갈래 구성비" in row["blocked_reason"] + assert handoff["missing_method_classes"] == [] # 시공법 안내에 「암」을 안 띄움 + + from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill + + bill = build_bill(handoff) + assert any("입력이 필요합니다 — 암 갈래 구성비" in m["reason"] for m in bill.missing) + + def test_토사는_시공법이_필요없음() -> None: handoff = build_handoff(summary_table=집계표(집계줄("흙깎기", "토사", 100.0))) assert handoff["work_items"][0]["work_item_code"] == "FP-09-03-02" @@ -533,11 +552,12 @@ def test_자재가_없으면_not_applied() -> None: assert handoff["surcharge_status"] == "not_applied" -def test_관급구분_미분류가_계약값임() -> None: - """`unknown` 은 세 번째 값이다 — B09 는 이 줄을 관급에도 도급에도 안 넣는다.""" +def test_관급구분_안_정하면_기본_사급() -> None: + """2026-09-14 브레인 판정 ⓐ — 안 정한 자재는 사급으로 인계(관급은 산출 조건이 정함).""" unit = build_unit_table([구조물()]) handoff = build_handoff(unit_quantity_table=unit, material_table=build_material_table(unit)) - assert all(row["supply_type"] == "unknown" for row in handoff["materials"]) + assert handoff["materials"] + assert all(row["supply_type"] == "contractor_supplied" for row in handoff["materials"]) # ── 운반 줄 (2026-09-07 조율 창 요청) ─────────────────────────────── @@ -586,9 +606,11 @@ def 운반표() -> dict: def test_운반계획이_있으면_네_줄이_실림() -> None: handoff = build_handoff(haul_table=운반표()) haul_rows = [row for row in handoff["work_items"] if row["origin"] == "haul"] - assert len(haul_rows) == 4 + # 운반 넷 + 덤프 운반 둘의 적재 짝 줄 둘(2026-09-14 · 10-12 「1. 적재」 — 거리 없음) + moves = [row for row in haul_rows if row["haul_equipment"] != "dump_loading"] + assert len(moves) == 4 and len(haul_rows) == 6 assert all(row["haul_equipment"] for row in haul_rows) - assert all(row["haul_distance_m"] for row in haul_rows) + assert all(row["haul_distance_m"] for row in moves) def test_무대가_함께_와야_운반_검산이_걸림() -> None: @@ -598,7 +620,7 @@ def test_무대가_함께_와야_운반_검산이_걸림() -> None: assert free["in_bill"] is False assert free["quantity"] == pytest.approx(871.0) assert handoff["excluded_row_count"] == 1 - assert handoff["bill_row_count"] == 3 + assert handoff["bill_row_count"] == 5 # 도자 · 덤프 운반 둘 · 덤프 적재 짝 줄 둘 def test_운반계획이_없으면_줄이_없음() -> None: @@ -791,11 +813,10 @@ def 조각(row: dict, code_fragment: str) -> dict: def test_조각마다_수량과_단위가_실릴것() -> None: """코드만 보내면 받는 쪽이 상세 줄을 못 세운다.""" row = build_handoff(unit_quantity_table=옹벽단위())["work_items"][0] - assert 조각(row, "12-01-01")["quantity"] == pytest.approx( - 15.0 - ) # 콘크리트 1.35 + 버림 0.15, ×10m + # 콘크리트 1.345 + 버림 0.145, ×10m — 2026-09-14 원문 값(전 라이브러리 1.35 + 0.15 → 15.0) + assert 조각(row, "12-01-01")["quantity"] == pytest.approx(14.9) assert 조각(row, "12-04")["quantity"] == pytest.approx(6.0) - assert 조각(row, "12-38")["quantity"] == pytest.approx(32.0) + assert 조각(row, "12-38")["quantity"] == pytest.approx(32.05) def test_철근은_ton_으로_환산될것() -> None: @@ -862,12 +883,12 @@ def test_기초잡석은_버림_폭에서_선다() -> None: 그 조각이 **채워졌다**(2026-09-09 확정 3차 ②). 버림 폭이 곧 잡석다짐 폭이라 (KCS 34 50 05) 두께 비만 곱하면 관측 원단위 구조물에도 값이 선다 — - 버림 1.5㎥ × (0.2 ÷ 0.1) = 3.0㎥(= 0.30㎥/m × 10m). + 버림 1.45㎥ × (0.2 ÷ 0.1) = 2.9㎥(= 0.29㎥/m × 10m · 2026-09-14 원문 버림 0.145, 전 3.0). 「못 채운 조각은 0 이 아니라 사유와 함께」라는 규칙 자체는 다른 조각 시험이 지킨다. """ row = build_handoff(unit_quantity_table=옹벽단위())["work_items"][0] gravel = 조각(row, "12-25") - assert gravel["quantity"] == 3.0 + assert gravel["quantity"] == pytest.approx(2.9) assert not gravel.get("not_ready") diff --git a/resources/tester/test_b08_handoff_contract.py b/resources/tester/test_b08_handoff_contract.py index f27312bf..8f4d9d8a 100644 --- a/resources/tester/test_b08_handoff_contract.py +++ b/resources/tester/test_b08_handoff_contract.py @@ -131,10 +131,18 @@ def test_막힌_까닭이_세_갈래_중_하나일것() -> None: from B08_Quantity.B08_Quantity_Engine_Handoff import ( BLOCKED_FORMULA_MISSING, BLOCKED_INPUT_MISSING, + BLOCKED_UNCONFIRMED, BLOCKED_UNIT_DATA_MISSING, ) - allowed = {None, BLOCKED_INPUT_MISSING, BLOCKED_UNIT_DATA_MISSING, BLOCKED_FORMULA_MISSING} + # ⭐ 넷째 갈래(2026-09-14 브레인 판정) — 치수 없이 기본값으로 선 줄: 수량은 보이되 금액 밖. + allowed = { + None, + BLOCKED_INPUT_MISSING, + BLOCKED_UNIT_DATA_MISSING, + BLOCKED_FORMULA_MISSING, + BLOCKED_UNCONFIRMED, + } for row in _rows()["work_items"]: assert row["blocked_kind"] in allowed diff --git a/resources/tester/test_b08_material_summary.py b/resources/tester/test_b08_material_summary.py index 98d71ebc..53ccd9f8 100644 --- a/resources/tester/test_b08_material_summary.py +++ b/resources/tester/test_b08_material_summary.py @@ -103,7 +103,7 @@ def test_품셈에_포함된_항목은_또_붙이지_않음() -> None: table = build_table(원단위표(성분("모래", "㎥", 10.0, surcharge_included=True))) row = 줄(table, "모래") assert row["total_amount"] == pytest.approx(10.0) - assert row["note"] == NOTE_INCLUDED + assert row["note"].split(" · ")[0] == NOTE_INCLUDED # 뒤는 「기본 사급」 # ── destination 가르기 ────────────────────────────────────────────── @@ -214,7 +214,7 @@ def test_표에_없는_자재는_0퍼센트로_넘기지_않음() -> None: table = build_table(원단위표(성분("낯선자재", "㎥", 10.0))) row = 줄(table, "낯선자재") assert row["surcharge_pct"] is None - assert row["note"] == NOTE_RATE_MISSING + assert row["note"].split(" · ")[0] == NOTE_RATE_MISSING # 뒤는 「기본 사급」 assert "낯선자재" in table["missing_rate_materials"] # 값은 잃지 않는다 — 순수량 그대로 둔다. assert row["total_amount"] == pytest.approx(10.0) @@ -252,13 +252,40 @@ def test_합계는_반올림하지_않음() -> None: # ── 관급/사급 ─────────────────────────────────────────────────────── -def test_안_정한_자재는_미정으로_드러남() -> None: - """⚠ 표기에 **누가 정하는지**가 있어야 한다 — 「미분류」로만 적으면 우리가 못 만든 것처럼 - 읽힌다(2026-09-09 화면 확인). 값(`unknown`)과 판정은 그대로다.""" +def test_안_정한_자재는_기본_사급으로_서고_그_사실이_적힘() -> None: + """2026-09-14 브레인 판정 ⓐ — 기본 사급 · 관급은 산출 조건이 정함(종전 `unknown` 은 어느 + 합계에도 안 들어 자재비가 통째로 빠짐). 명시적으로 미정을 고른 줄은 그대로 미정.""" table = build_table(원단위표(성분("야면석", "㎥", 5.0))) - assert 줄(table, "야면석")["supply"] == SUPPLY_UNKNOWN - assert 줄(table, "야면석")["supply_label"] == "미정(발주기관 결정)" - assert table["missing_supply_materials"] == ["야면석"] + assert 줄(table, "야면석")["supply"] == "contractor_supplied" + assert "기본 사급" in 줄(table, "야면석")["note"] + assert table["missing_supply_materials"] == [] + picked = build_table(원단위표(성분("야면석", "㎥", 5.0)), supply_map={"야면석": "unknown"}) + assert 줄(picked, "야면석")["supply"] == SUPPLY_UNKNOWN + assert 줄(picked, "야면석")["supply_label"] == "미정(발주기관 결정)" + assert picked["missing_supply_materials"] == ["야면석"] + + +def test_율이_없는_까닭이_줄에_뜸() -> None: + """보류(조건이 다름)와 이름 없음을 가름 — 「미확보」만으로는 할 일이 안 보임.""" + table = build_table(원단위표(성분("막자갈", "㎥", 10.0), 성분("못", "㎏", 1.0))) + assert 줄(table, "막자갈")["surcharge_pct"] is None + assert "조건이 다름 — 사용자 확정 후 옮길 것" in 줄(table, "막자갈")["note"] + assert "할증률표에 이름 없음" in 줄(table, "못")["note"] + + +def test_할증률은_표값_이내에서만_고름() -> None: + """1-3-1 「표의 값 이내」 — 0~표값만 받고 넘거나 표에 없는 자재는 안 씀.""" + fake = SurchargeTable(rates={"판재": {"material": "판재", "rate": 10}}) + unit = 원단위표(성분("판재", "㎥", 1.0), 성분("낯선자재", "㎥", 1.0)) + lower = build_table(unit, surcharge_table=fake, surcharge_overrides={"판재": 6, "낯선자재": 3}) + assert 줄(lower, "판재")["surcharge_pct"] == 6 and 줄(lower, "판재")["surcharge_cap"] == 10 + assert "표 상한 10% 이내" in 줄(lower, "판재")["note"] + assert 줄(lower, "낯선자재")["surcharge_pct"] is None # 표에 없으면 고른 값도 안 씀 + over = build_table(unit, surcharge_table=fake, surcharge_overrides={"판재": 12}) + assert ( + 줄(over, "판재")["surcharge_pct"] == 10 + and "이내가 아니라 안 씀" in 줄(over, "판재")["note"] + ) def test_설정이_정한_구분을_따름() -> None: diff --git a/resources/tester/test_b08_observed_unit.py b/resources/tester/test_b08_observed_unit.py index 14d29b21..ee911672 100644 --- a/resources/tester/test_b08_observed_unit.py +++ b/resources/tester/test_b08_observed_unit.py @@ -51,8 +51,9 @@ def 성분(result, name: str): def test_규격이_맞으면_연장만큼_곱해짐() -> None: """`H=2.0 옹벽 10m` 는 같은 단면이 10m 이어진 것 — 개수를 세는 것이지 규격을 늘리는 게 아니다.""" result = expand(옹벽(length=10.0)) - assert 성분(result, "콘크리트").amount == pytest.approx(13.5) # 1.35 ㎥/m × 10 - assert 성분(result, "유로폼").amount == pytest.approx(32.0) + # 원문 값 1.345 ㎥/m × 10 · 유로폼 3.205(2026-09-14 · 전 라이브러리 1.35 → 13.5 · 3.20 → 32.0) + assert 성분(result, "콘크리트").amount == pytest.approx(13.45) + assert 성분(result, "유로폼").amount == pytest.approx(32.05) def test_높이가_다르면_비례로_늘리지_않고_미확보() -> None: @@ -114,8 +115,11 @@ def test_표에도_근거가_실림() -> None: 관측이 아니라 `derived` 다(2026-09-09 확정 3차 ②). 근거가 갈려 실리는 것이 맞다.""" table = build_table([옹벽()]) components = [c for s in table["structures"] for c in s["components"]] - kinds = {c["basis_kind"] for c in components if c["name"] != "기초잡석"} + # 2026-09-14 — 단면으로 세운 터파기·되메우기·잔토도 파생 줄(브레인 판정 ①). + derived = {"기초잡석", "터파기", "되메우기", "잔토처리"} + kinds = {c["basis_kind"] for c in components if c["name"] not in derived} assert kinds == {BASIS_OBSERVED} + assert {c["basis_kind"] for c in components if c["name"] in derived} == {BASIS_DERIVED} 잡석 = next(c for c in components if c["name"] == "기초잡석") assert 잡석["basis_kind"] == BASIS_DERIVED diff --git a/resources/tester/test_b08_rebar_name_spec.py b/resources/tester/test_b08_rebar_name_spec.py new file mode 100644 index 00000000..0a7f0cd4 --- /dev/null +++ b/resources/tester/test_b08_rebar_name_spec.py @@ -0,0 +1,116 @@ +"""철근 이름·규격 가르기(2026-09-14 · 목록 3+4) — **가르기 전후 물량이 그대로**인지 잼. + +「이형철근 D13」 한 칸을 이름 「이형철근」 + 규격 「D13」 으로 가르면 셋이 같은 글자에 묶여 있어 +하나만 고치면 조용히 어긋남(브레인 경고): ① 묶음 조각 `from_components` · `amounts[name]` +(D13·D16 이 서로 덮어씀) ② 이중계상 경계 검사 `_pickers`·`destinations`(못 집으면 검사가 놂) +③ 자재총괄 줄·할증. 이 시험은 **가르기 전 코드에서 먼저 초록**으로 만들고 가른 뒤에도 초록이어야 함. + + 옹벽 반중력식 10m D13 13.45 · D16 30.42 ㎏/m → 조각 0.4387 ton · 자재 134.5 · 304.2 ㎏ + 집수정 □형 Ø800 1개소 D13 4.776 ㎏ + L형수로 H=0.2 10m D13 0.398 ㎏/m → 3.98 ㎏ +""" + +from __future__ import annotations + +import copy +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff, load_mapping # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_Handoff_Boundaries import ( # noqa: E402 + verify_double_count_boundaries, +) +from B08_Quantity.B08_Quantity_Engine_MaterialSummary import build_table # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit # noqa: E402 + + +def _구조물들() -> dict: + return build_unit( + [ + { + "structure_id": "w1", + "type_id": "retaining_wall", + "start_m": 100.0, + "end_m": 110.0, + "options": {"form": "반중력식", "height_m": 2.0, "length_m": 10.0}, + }, + { + "structure_id": "b1", + "type_id": "pipe_inlet_basin", + "start_m": 200.0, + "end_m": 200.0, + "options": { + "inlet_basin_form": "□형(기본형)", + "inlet_basin_material": "콘크리트", + "pipe_diameter_mm": "800", + }, + }, + { + "structure_id": "d1", + "type_id": "open_ditch", + "start_m": 300.0, + "end_m": 310.0, + "options": {"ditch_spec": "L형수로 H=0.2", "length_m": 10.0}, + }, + ], + {"retaining_wall": "옹벽", "pipe_inlet_basin": "집수정", "open_ditch": "개거"}, + ) + + +def test_옹벽_철근_조각은_D13_D16_을_다_모아_ton() -> None: + row = next( + r + for r in build_handoff(unit_quantity_table=_구조물들())["work_items"] + if r.get("composite_parts") + ) + rebar = next(p for p in row["composite_parts"] if "12-03" in str(p.get("code"))) + assert rebar["quantity"] == pytest.approx(0.4387) and not rebar.get("not_ready") + from B08_Quantity.B08_Quantity_Engine_Handoff import structure_kind + + wall = next(s for s in _구조물들()["structures"] if s["type_id"] == "retaining_wall") + assert structure_kind(wall) == "철근구조물" + + +def test_자재총괄_철근_줄_물량과_할증이_그대로() -> None: + table = build_table(_구조물들()) + rebar = { + row["supply_key"]: (row["net_amount"], row["surcharge_pct"], row["total_amount"]) + for row in table["rows"] + if row["supply_key"].startswith("이형철근") + } + assert set(rebar) == {"이형철근 D13", "이형철근 D16"} # 관급구분·단가 키는 전후 같은 글자 + assert rebar["이형철근 D13"][0] == pytest.approx(134.5 + 4.776 + 3.98) + assert rebar["이형철근 D16"][0] == pytest.approx(304.2) + assert rebar["이형철근 D13"][1] == 3 and rebar["이형철근 D16"][1] == 3 # 1-3-1 이형철근 + assert "이형철근" not in " ".join(table["missing_rate_materials"]) + + +def test_경계_검사가_철근을_여전히_집는다() -> None: + """철근이 토공 축으로 잘못 가면 묶음 조각이 또 센다고 걸려야 함 — 못 집으면 검사가 놂.""" + unit = copy.deepcopy(_구조물들()) + wall = next(s for s in unit["structures"] if s["type_id"] == "retaining_wall") + for component in wall["components"]: + if str(component["name"]).startswith("이형철근"): + component["destination"] = "earthwork" + found = verify_double_count_boundaries(unit, None, load_mapping()) + assert any("이형철근" in text and "묶음 조각" in text for text in found), found + assert not verify_double_count_boundaries(_구조물들(), None, load_mapping()) + + +def test_가른_뒤_이름과_규격이_따로_서고_할증은_별칭_없이_이어짐() -> None: + table = build_table(_구조물들()) + rows = [(r["name"], r["spec"]) for r in table["rows"] if r["name"].startswith("이형철근")] + assert sorted(rows) == [("이형철근", "D13"), ("이형철근", "D16")] + unit = _구조물들() + specs = { + (c["name"], c.get("spec")) + for s in unit["structures"] + for c in s["components"] + if str(c["name"]).startswith("이형철근") + } + assert specs == {("이형철근", "D13"), ("이형철근", "D16")} diff --git a/resources/tester/test_b08_rubble_base.py b/resources/tester/test_b08_rubble_base.py index b5e9fcfa..eb31e5b9 100644 --- a/resources/tester/test_b08_rubble_base.py +++ b/resources/tester/test_b08_rubble_base.py @@ -3,7 +3,8 @@ ⚠ 겨누는 것 여섯 ① 두께는 **0.2 m**(사용자 확정) — 품셈 12-25 는 ㎥당 품만 주고 두께를 안 정함 ② 폭을 다시 세지 않는다 — **버림 폭이 곧 잡석다짐 폭**(KCS 34 50 05)이라 두께 비만 곱함 - ③ ⚠ 그래서 **관측 원단위로 오는 옹벽에도 값이 선다**(버림 0.15㎥/m ⇒ 잡석 0.30㎥/m) + ③ ⚠ 그래서 **관측 원단위로 오는 옹벽에도 값이 선다**(버림 0.145㎥/m ⇒ 잡석 0.29㎥/m + · 2026-09-14 원문 값 — 전 라이브러리 반올림 0.15 ⇒ 0.30) ④ 두께는 화면에서 바꿀 수 있다 ⑤ ⚠ 자재총괄에 안 섞인다 — 운반·부설·다짐 품이 붙는 **공종**이다 ⑥ ⚠ 묶음으로 서는 구조물은 줄을 또 세우지 않는다(옹벽 조각이 이미 셈) @@ -86,7 +87,7 @@ def test_버림_두께_비로_나온다() -> None: def test_관측_원단위_구조물에도_선다() -> None: """⚠ 옹벽은 관측 원단위로 오는데 그 표에 기초잡석 줄이 없다 — 버림에서 나온다.""" - assert abs(성분(옹벽(), "기초잡석")["amount"] - 3.0) < 1e-9 # 0.30㎥/m × 10m + assert abs(성분(옹벽(), "기초잡석")["amount"] - 2.9) < 1e-9 # 0.29㎥/m × 10m def test_두께를_바꾸면_따라간다() -> None: @@ -112,4 +113,4 @@ def test_묶음_구조물은_줄을_또_세우지_않는다() -> None: assert all(row["name"] != "기초잡석" for row in rows) parts = rows[0]["composite_parts"] gravel = next(part for part in parts if part["code"] == "FP-12-25") - assert gravel["quantity"] == 3.0 + assert abs(gravel["quantity"] - 2.9) < 1e-9 diff --git a/resources/tester/test_b08_spoil_haul.py b/resources/tester/test_b08_spoil_haul.py index 86bfc359..50c83b92 100644 --- a/resources/tester/test_b08_spoil_haul.py +++ b/resources/tester/test_b08_spoil_haul.py @@ -167,6 +167,37 @@ def test_사토도_자연상태로_선다() -> None: assert "자연상태" in rows[0]["spec_detail"] +def test_사토에도_적재_짝_줄이_서고_단가가_선다() -> None: + """사토도 싣는 일이 따로(10-12 「1. 적재」) — 내역에 서는 운반 줄에만 붙고, 검산엔 안 셈.""" + from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill + from B09_Estimation.B09_Estimation_MachineProductivity_Dump import dump_haul_distances + from B09_Estimation.B09_Estimation_UnitPrice import cached_build + + payload = build_handoff( + haul_table={ + "rows": [], + "spoil": { + "volume_m3": 100.0, + "distance_m": 800.0, + "by_ground_m3": {"ea_m3": 70.0}, + "ground_unknown_m3": 30.0, + }, + } + ) + rows = payload["work_items"] + loads = [row for row in rows if row["name"] == "덤프 적재"] + assert [(row["variant_value"], row["quantity"]) for row in loads] == [("토사", 70.0)] + assert loads[0]["haul_distance_m"] is None and loads[0]["haul_equipment"] == "dump_loading" + # 갈래 못 붙인 몫은 막힌 줄이라 짝 줄이 없음 + assert len([row for row in rows if row["name"] == "사토 운반"]) == 2 + + bill = build_bill(payload, build=cached_build(dump_haul_m=dump_haul_distances(payload))) + got = {row.name: row for row in bill.rows if row.name in ("사토 운반", "덤프 적재")} + assert got["사토 운반"].price_code.startswith("B-FP-10-12-01#L800") + assert got["덤프 적재"].price_code == "B-FP-10-12-01#적재" + assert got["사토 운반"].amount_krw > 0 and got["덤프 적재"].amount_krw > 0 + + def test_되돌린_값이_없으면_다짐_그대로_두고_사유() -> None: rows = 줄들(volume_m3=61.88, distance_m=800.0, by_ground_m3={"rr_m3": 61.88}) assert rows[0]["quantity"] == 61.88 diff --git a/resources/tester/test_b08_stmate_fixed_chain.py b/resources/tester/test_b08_stmate_fixed_chain.py new file mode 100644 index 00000000..d7d96d63 --- /dev/null +++ b/resources/tester/test_b08_stmate_fixed_chain.py @@ -0,0 +1,121 @@ +"""고정형 항목 사슬 ㉮ — 인계·내역으로 가는 문(2026-09-14 브레인 판정 「공종 코드가 없으면 어디에도 안 감」). + +실측(936be972): 뽑은 고정형엔 일위대가 구성(`unit_price.rows`)이 없어 `priced_sheets` 가 그 장을 안 셈 → +구조물 줄 수량 0 · 내역 줄 사라짐 · 본체 −4,062,480. 고친 길: 일위대가로 가는 줄마다 구성 줄을 두되 +**공종 코드는 비움** → 그 장은 호표 줄로 인계되고 금액은 「미완 — 공종 코드 미정」(조용한 0 아님) → +사용자가 고르개로 코드를 고르면 금액이 섬. 자원 축은 후보만(이름 자동 확정 금지 · 명세 2장). +""" + +from __future__ import annotations + +import sys +from decimal import Decimal +from pathlib import Path +from types import SimpleNamespace + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_StmateRecipe import recipe_item # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_StructurePriceLink import ( # noqa: E402 + price_sheets, + priced_sheets, +) +from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import with_rows # noqa: E402 +from B09_Estimation.B09_Estimation_PriceBook import Money3, PriceBookError # noqa: E402 + +HOPYO = { + "no": 6, + "source_code": "B00010", + "name": "기슭막이", + "spec": "H=2.0m", + "unit": "M", + "basis": [], + "contract_rows": 0, + "rows": [ + { + "name": "깬잡석채집", + "spec": "L=45cm", + "amount": 2.09, + "unit": "M2", + "remark": "", + "source_code": "", + }, + { + "name": "고임돌채집", + "spec": "기계", + "amount": 0.31, + "unit": "M3", + "remark": "", + "source_code": "", + }, + ], +} + + +class Book: + titles = {"B-FP-X#채집": ("깬잡석 채집", "㎡", Money3(Decimal(0), Decimal(1000), Decimal(0)))} + + def title(self, code: str) -> SimpleNamespace: + if code not in self.titles: + raise PriceBookError(f"단가표에 없는 코드입니다: {code}") + name, unit, _money = self.titles[code] + return SimpleNamespace(name=name, spec="", unit=unit) + + def resolve(self, code: str) -> Money3: + return self.titles[code][2] if code in self.titles else self.title(code) + + +def _sheet(item: dict) -> dict: + """`apply_templates` 가 낸 장 모양 — 고정형 줄 = 박힌 단위량.""" + return { + "type_id": item["type_id"], + "title": "돌쌓기(찰) H=2.5", + "formula_sheet": {"vars": {}}, + "billing_unit": "m", + "members": [{"structure_id": "s1", "billing_quantity": 10}], + "rows": [ + { + "no": row["seq"], + "unit_amount": float(row["amount"]), + "unit": row["unit"], + "destination": row["destination"], + } + for row in item["rows"] + ], + } + + +def test_고정형_장이_호표_줄로_인계되고_코드_없으면_미완() -> None: + item = { + **recipe_item(HOPYO, type_id="masonry_wet", file_name="a.xlsx", project="p"), + "code": "AX-ST-0000abcd", + } + entries = priced_sheets([_sheet(item)], {"masonry_wet": item}) + assert [e["structure_ids"] for e in entries] == [["s1"]] # 전엔 빈 목록 — 인계에서 끊겼음 + prices = price_sheets(entries, Book(), lambda code, value: None, [item]) + (entry,) = prices.values() + assert entry["blocked"] == 2 and entry["total"] == 0 + assert all("공종 코드 미정" in reason for reason in entry["reasons"]) + + +def test_찾기는_코드_없는_줄_이름으로_후보만_띄운다() -> None: + ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet_UnitPriceEdit.ts").read_text( + encoding="utf-8" + ) + assert "query.value = row.name" in ui and "!row.ref_code && !row.work_item_code" in ui + + +def test_사용자가_코드를_고르면_금액이_선다() -> None: + item = { + **recipe_item(HOPYO, type_id="masonry_wet", file_name="a.xlsx", project="p"), + "code": "AX-ST-0000abcd", + } + picked = [ + dict(row, ref_code="B-FP-X#채집", unit="㎡") for row in item["unit_price"]["rows"][:1] + ] + template = with_rows(item, picked) + entries = priced_sheets([_sheet(item)], {"masonry_wet": template}) + (entry,) = price_sheets(entries, Book(), lambda code, value: None, [template]).values() + assert entry["blocked"] == 0 and entry["total"] == Decimal("2090") # 2.09㎡ × 1,000원 diff --git a/resources/tester/test_b08_stmate_library_router.py b/resources/tester/test_b08_stmate_library_router.py new file mode 100644 index 00000000..1df6376d --- /dev/null +++ b/resources/tester/test_b08_stmate_library_router.py @@ -0,0 +1,108 @@ +"""라이브러리 — STmate 출력 엑셀에서 고정형 항목 뽑아 넣기 창구(PLAN 4장 · 2026-09-14 브레인 판정). + +두 걸음: [읽기]는 호표 수·구성 줄 수·못 읽은 사유만 돌려주고 아무것도 안 씀 → [넣기]는 같은 파일을 +서버가 **다시 읽어** 고른 호표를 개인 단에 씀(브라우저가 보낸 줄을 받아 적지 않음 · 개인 단만 · 판정 Ⓗ). +""" + +from __future__ import annotations + +import io +import json +import sys +from pathlib import Path + +import openpyxl +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import B08_Quantity.B08_Quantity_Engine_StructureLibrary as library_module # noqa: E402 +import B08_Quantity.B08_Quantity_Router_StmateLibrary as router_module # noqa: E402 +from common_util.common_util_auth import verify_session # noqa: E402 + +PROJECT_ID = "33333333-3333-3333-3333-333333333333" +BASE = f"/api/projects/{PROJECT_ID}/quantity/structure-sheets/library/stmate" + + +def _xlsx() -> bytes: + book = openpyxl.Workbook() + ws = book.active + ws.title = "일위대가표" + for row in [ + ["일 위 대 가 표"], + ["공사명 : 시험 공사"], + ["명 칭", "규 격", "수 량", "단위"], + [None], + [" 제 1 호표"], + ["돌기슭막이(메쌓기)", "H=2.0", None, "m"], + ["메쌓기", "L3=55cm이하", 2.09, "m2"], + ["고임돌채집", "기계", 0.31, "m3"], + ["합 계"], + [" 제 2 호표"], + ["규준틀설치", "종단", None, "개소"], + ["각재", "외송", "약간", "M3"], + ["합 계"], + ]: + ws.append(row) + buffer = io.BytesIO() + book.save(buffer) + return buffer.getvalue() + + +@pytest.fixture() +def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient: + monkeypatch.setattr(library_module, "STORAGE_BASE_DIR", str(tmp_path / "storage")) + app = FastAPI() + app.include_router(router_module.router) + app.dependency_overrides[verify_session] = lambda: {"company_id": 7, "user_id": 42} + return TestClient(app) + + +def _files() -> dict: + return {"file": ("견본.xlsx", _xlsx(), "application/octet-stream")} + + +def test_읽기는_수와_사유만_주고_안_쓴다(client: TestClient, tmp_path: Path) -> None: + body = client.post(f"{BASE}/read", files=_files()).json() + assert body["counts"] == {"hopyo": 1, "rows": 2} + assert body["hopyo"][0]["name"] == "돌기슭막이(메쌓기)" and body["hopyo"][0]["rows"] == 2 + assert any("C12" in p for p in body["problems"]) # 수량이 수가 아닌 2호표는 사유로 + assert not (tmp_path / "storage").exists() + + +def test_넣기는_서버가_다시_읽어_개인_단에_고정형으로(client: TestClient, tmp_path: Path) -> None: + response = client.post( + f"{BASE}/save", files=_files(), data={"hopyo_no": "1", "type_id": "masonry_dry"} + ) + assert response.status_code == 200, response.text + code = response.json()["code"] + saved = json.loads( + (tmp_path / "storage" / "7" / "42" / "library" / f"{code}.json").read_text(encoding="utf-8") + ) + assert saved["library_tier"] == "personal" and saved["origin"]["project"] == "시험 공사" + assert [r["amount"] for r in saved["rows"]] == ["2.09", "0.31"] + dirs = library_module.tier_dirs(7, 42) + assert [i["kind"] for i in library_module.list_items(dirs, "masonry_dry")] == ["fixed"] + + +def test_회사_없으면_403_없는_호표는_404_모르는_종류는_400(client: TestClient) -> None: + bad = client.post( + f"{BASE}/save", files=_files(), data={"hopyo_no": "2", "type_id": "masonry_dry"} + ) + assert bad.status_code == 404 # 못 읽은 호표는 넣을 수 없음 + unknown = client.post(f"{BASE}/save", files=_files(), data={"hopyo_no": "1", "type_id": "없음"}) + assert unknown.status_code == 400 + client.app.dependency_overrides[verify_session] = lambda: {"company_id": None, "user_id": 1} + denied = client.post( + f"{BASE}/save", files=_files(), data={"hopyo_no": "1", "type_id": "masonry_dry"} + ) + assert denied.status_code == 403 + + +def test_앱에_창구가_걸린다() -> None: + main = (ROOT / "main.py").read_text(encoding="utf-8") + assert "B08_Quantity_Router_StmateLibrary" in main and "b08_stmate_library_router" in main diff --git a/resources/tester/test_b08_stmate_library_ui.py b/resources/tester/test_b08_stmate_library_ui.py new file mode 100644 index 00000000..5bed025d --- /dev/null +++ b/resources/tester/test_b08_stmate_library_ui.py @@ -0,0 +1,26 @@ +"""라이브러리 칸의 [STmate 엑셀에서 뽑기] — 읽은 수를 보이고 묻고 넣는 두 걸음(2026-09-14 브레인 판정). + +⚠ 읽기 전에 넣기가 열리면 안 됨 · 넣기 전에 호표 수·구성 줄 수·덮어씀을 묻는 확인이 있어야 함 · + 넣기는 **파일을 다시 보냄**(서버가 다시 읽음 — 화면이 읽은 줄을 보내지 않음). +""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +UI = ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet_Stmate.ts" +PANEL = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet_Library.ts").read_text( + encoding="utf-8" +) + + +def test_라이브러리_칸에_뽑기_칸이_붙는다() -> None: + assert "buildStmatePanel(" in PANEL + + +def test_읽고_수를_보이고_묻고_파일을_다시_보내_넣는다() -> None: + ui = UI.read_text(encoding="utf-8") + assert "/stmate/read" in ui and "/stmate/save" in ui + assert 'form.append("file"' in ui and 'form.append("hopyo_no"' in ui + assert "window.confirm(" in ui and "counts.hopyo" in ui and "counts.rows" in ui + assert "put.hidden = true" in ui # 읽기 전에는 넣기 단추가 숨음 + assert "rows:" not in ui.split("/stmate/save")[1][:400] # 줄을 보내지 않음 diff --git a/resources/tester/test_b08_stmate_recipe_read.py b/resources/tester/test_b08_stmate_recipe_read.py new file mode 100644 index 00000000..75d4d479 --- /dev/null +++ b/resources/tester/test_b08_stmate_recipe_read.py @@ -0,0 +1,133 @@ +"""STmate 출력 엑셀 「일위대가표」 읽개 — 고정형 라이브러리 항목의 둘째 길(PLAN 4장 · 2026-09-14 브레인 판정 ①). + +왜 엑셀인가 — STC 는 호표(B) 구성이 난독화된 BDQTY 에만 있어 못 읽음(7파일 전수 확인) · +경쟁사 보호 장치는 풀지 않음. 출력 엑셀은 평문이고 코덱스 `recipe_extract.py` 가 실무 6건 누락 0 으로 뽑은 모양. +⚠ **모양이 다르면 억지로 읽지 않음** — 「어느 칸이 안 맞는지」 사유로 돌려보냄(추측해서 맞추면 + 수량이 조용히 틀린 채 라이브러리에 들어가 계속 쓰임). +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import openpyxl +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_StmateRecipe import read_recipes # noqa: E402 + +PRACTICE = ROOT / "resources" / "knowledge" / "original" / "실무문서" +BONGHWA = next( + (p for p in PRACTICE.rglob("*.xlsx") if "기번41" in p.name and not p.name.startswith("~$")), + None, +) + + +def _book(tmp_path: Path, rows: list[list[object]], sheet: str = "일위대가표") -> Path: + book = openpyxl.Workbook() + ws = book.active + ws.title = sheet + for row in rows: + ws.append(row) + path = tmp_path / "book.xlsx" + book.save(path) + return path + + +HEAD = [ + ["일 위 대 가 표"], + ["공사명 : 시험"], + ["명 칭", "규 격", "수 량", "단위", "합 계"], + [None], +] + + +@pytest.mark.skipif(BONGHWA is None, reason="실무 엑셀(지식DB 원문) 없음") +def test_봉화_호표_45_기슭막이_H2_구성_9줄() -> None: + read = read_recipes(BONGHWA) + assert read["problems"] == [] + assert len(read["hopyo"]) == 45 + target = next( + h + for h in read["hopyo"] + if h["name"] == "기슭막이(깬잡석,찰쌓기 L3=45m)" and h["spec"] == "H=2.0m, 채집" + ) + assert target["unit"] == "M" and target["source_code"] == "B00010" + rows = target["rows"] + assert len(rows) == 9 + assert (rows[0]["name"], rows[0]["spec"], rows[0]["amount"], rows[0]["unit"]) == ( + "깬잡석채집", + "L=45cm 내외", + 2.09, + "M2", + ) + assert rows[1]["source_code"] == "B00004" # 하위 호표 참조(한 단만 — 판정 Ⓕ) + assert rows[-1]["name"] == "콘크리트믹서사용" and rows[-1]["amount"] == 0.42 + + +def test_머리_칸이_다르면_안_읽고_칸을_짚는다(tmp_path: Path) -> None: + head = [row[:] for row in HEAD] + head[2][2] = "수량합" + read = read_recipes(_book(tmp_path, [*head, [" 제 1 호표"], ["벽", "H=1", None, "m"]])) + assert read["hopyo"] == [] + assert any("C3" in p and "수량" in p for p in read["problems"]) + + +def test_수량이_수가_아니면_그_호표를_안_읽는다(tmp_path: Path) -> None: + rows = [ + *HEAD, + [" 제 1 호표"], + ["벽", "H=1", None, "m"], + ["돌", "", "약 2", "㎡"], + ["합 계"], + [" 제 2 호표"], + ["담", "H=2", None, "m"], + ["돌", "", 3, "㎡"], + ["합 계"], + ] + read = read_recipes(_book(tmp_path, rows)) + assert [h["name"] for h in read["hopyo"]] == ["담"] # 틀린 호표는 통째로 뺌 + assert any("C7" in p for p in read["problems"]) + + +def test_착공판_계약단가_줄은_안_뽑고_항목에_적는다(tmp_path: Path) -> None: + """판정 ㉮㉯㉰ — 별산은 자재총괄 · % 가산 행은 참고 + 항목 사유 · 계약단가(낙찰률)는 안 뽑음 · 근거 줄 보존.""" + rows = [ + *HEAD, + [" 제 1 호표"], + ["기슭막이", "H=2.0", None, "m"], + ["건설표준품셈", "7-1-1(메쌓기)", 0, None], + ["파쇄암", "별도계상", 2.09, "㎡", *[0] * 8, "별산자재 25 "], + ["메쌓기", "L3=55cm이하", 2.09, "m2", *[0] * 8, "대가 11호표"], + ["공구손료", "노무비의 %", 2, "%"], + ["계"], + ["계약단가", None, 88.5], + ] + from B08_Quantity.B08_Quantity_Engine_StmateRecipe import recipe_item + + read = read_recipes(_book(tmp_path, rows)) + assert read["problems"] == [] and read["project"] == "시험" + item = recipe_item(read["hopyo"][0], type_id="masonry_dry", file_name="a.xlsx", project="시험") + assert [(r["name"], r["destination"]) for r in item["rows"]] == [ + ("파쇄암", "material"), + ("메쌓기", "unit_price"), + ("공구손료", "reference"), + ] + assert all(r["formula"] == "" and "7-1-1" in r["formula_text"] for r in item["rows"]) + assert item["rows"][1]["amount"] == "2.09" + assert "가산 행 1줄" in item["note"] and "공구손료" in item["note"] + assert "계약단가·낙찰율 줄 1줄" in item["note"] and "현행 품셈 대조 전" in item["note"] + assert item["item_kind"] == "fixed" and item["origin"]["project"] == "시험" + # ㉮ 일위대가 구성 — 일위대가로 가는 줄만(별산·가산 행 뺌) · 공종 코드는 비워 둠(사용자가 고름) + unit_rows = item["unit_price"]["rows"] + assert [(r["name"], r["from_row"]) for r in unit_rows] == [("메쌓기", 2)] + assert not any(r.get("work_item_code") or r.get("ref_code") for r in unit_rows) + + +def test_시트가_없으면_사유(tmp_path: Path) -> None: + read = read_recipes(_book(tmp_path, HEAD, sheet="다른표")) + assert read["hopyo"] == [] and any("일위대가표" in p for p in read["problems"]) diff --git a/resources/tester/test_b08_structure_figure.py b/resources/tester/test_b08_structure_figure.py new file mode 100644 index 00000000..ddf46501 --- /dev/null +++ b/resources/tester/test_b08_structure_figure.py @@ -0,0 +1,221 @@ +"""구조물도 상단 그림 — 그림 치수가 **수량표와 같은 한 벌**인가 (PLAN 12장). + +⚠ 옛 B07 그림은 두께 식을 따로 적어, 상부·하부 두께를 넣어도 **그림만 옛 두께**였음. +""" + +from __future__ import annotations + +import math +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_ObservedUnit import ( # noqa: E402 + expand_observed, + load_observed_table, +) +from B08_Quantity.B08_Quantity_Engine_StructureFigure import ( # noqa: E402 + attach_figures, + build_figure, + figure_reason, +) +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( # noqa: E402 + back_length_default_note, + stone_masonry, +) + + +def _sheet(**options) -> dict: + return { + "title": "돌쌓기(메)", + "type_id": "masonry_dry", + "height_m": 2.0, + "options": {"height_m": 2.0, **options}, + } + + +def _paths(shapes: list[dict]) -> list[dict]: + return [shape for shape in shapes if shape["kind"] == "path"] + + +def _area(points: list[list[float]]) -> float: + return abs(sum(x0 * y1 - x1 * y0 for (x0, y0), (x1, y1) in zip(points, points[1:]))) / 2 + + +def test_벽_두께는_사용자가_넣은_값을_따른다() -> None: + wall = _paths(build_figure(_sheet(back_len_cm=35, face_slope_ratio=0.3)))[0]["points"] + # 식: 상부 0.35+0.30=0.65 · 하부 0.65+0.30×(2−1)=0.95 · 기울기 0.3 → 앞면 위 끝 0.6 + assert wall[:4] == [[0.0, 0.0], [0.6, 2.0], [1.25, 2.0], [0.95, 0.0]] + given = _paths( + build_figure( + _sheet( + back_len_cm=35, face_slope_ratio=0.3, thickness_top_m=0.5, thickness_bottom_m=1.2 + ) + ) + )[0]["points"] + assert given[2] == [1.1, 2.0] and given[3] == [1.2, 0.0] + + +def test_터파기_넓이는_수량식과_같다() -> None: + dig = _paths(build_figure(_sheet(back_len_cm=35, face_slope_ratio=0.3)))[1] + assert dig["dash"] + assert abs(_area(dig["points"]) - 2.0 * ((0.65 + 0.95) / 2 + 0.2)) < 1e-9 + + +def test_못_그리는_장은_그림_대신_까닭이_실린다() -> None: + payload = { + "sheets": [ + _sheet(), + {**_sheet(stone_cm="60~80"), "type_id": "boulder_masonry"}, + _sheet(back_len_cm=45), + ] + } + attach_figures(payload) + no_back, boulder, drawn = payload["sheets"] + # 뒷길이 미정은 기본값이 있어 **그림이 섬**(2026-09-14 브레인 판정) — 까닭은 직경 축만. + assert no_back["figure_reason"] is None and no_back["figure"] + assert boulder["figure"] is None and "직경" in boulder["figure_reason"] + assert drawn["figure_reason"] is None and drawn["figure"] + + +def _texts(shapes: list[dict]) -> list[str]: + return [shape["text"] for shape in shapes if shape["kind"] == "text"] + + +def test_기본값으로_그린_장은_표_사유와_같은_말을_적는다() -> None: + options = {"height_m": 2.0} + _rows, table_notes = stone_masonry(2.0, 1.0, options, wet=False) + table_default = [note for note in table_notes if "뒷길이를 안 골라" in note] + # 2026-09-14 — 기본은 뒷길이 표준 하한(메 ≤3m 36~45㎝ → 45) · 표·그림이 같은 말. + assert table_default == [back_length_default_note(options, 45, wet=False, height_m=2.0)] + assert "하한 36㎝ 는 규격 일곱" in table_default[0] + + texts = _texts(build_figure(_sheet())) + assert table_default[0] in texts + assert "뒷길이 ℓ₃ = 45㎝ (기본)" in texts + # 두께도 기본 45 로 — 상부 0.45+0.30 + assert "상부 0.75 m" in texts + + chosen = _texts(build_figure(_sheet(back_len_cm=45))) + assert "뒷길이 ℓ₃ = 45㎝" in chosen and not any("안 골라" in text for text in chosen) + assert back_length_default_note({"back_len_cm": 45}, 45) is None + + +def _wall_sheet(**options) -> dict: + return { + "title": "옹벽 H=2", + "type_id": "retaining_wall", + "height_m": 2.0, + "options": {"height_m": 2.0, "form": "반중력식", **options}, + } + + +def test_옹벽_단면_치수가_표의_관측_수량을_그대로_낸다() -> None: + """표 = 그림 — 그림이 쓰는 단면으로 표 수량(라이브러리 반올림)이 다시 나와야 함.""" + entry = load_observed_table().find("retaining_wall", {"form": "반중력식", "height_m": 2.0}) + section = entry["section"] + amounts = {item["name"]: item["amount"] for item in entry["components"]} + footing, wall, key, blinding = (section[k] for k in ("footing", "wall", "key", "blinding")) + bottom = wall["top_m"] + wall["front_batter_m"] + wall["back_batter_m"] + assert abs(bottom - 0.45) < 1e-9 + concrete = ( + footing["width_m"] * footing["thickness_m"] + + key["width_m"] * key["depth_m"] + + (wall["top_m"] + bottom) / 2 * wall["height_m"] + ) + blind = blinding["thickness_m"] * ( + footing["width_m"] + 2 * blinding["overhang_m"] - key["width_m"] + ) + form = math.hypot(wall["height_m"], wall["back_batter_m"]) + math.hypot( + wall["height_m"], wall["front_batter_m"] + ) + pipe = wall["height_m"] / 2.0 * section["weep"]["pipe_length_m"] + # 원문 값 그대로(2026-09-14 브레인 판정 — 라이브러리 반올림 1.35·0.15·3.2 에서 바꿈) + assert abs(concrete - amounts["콘크리트"]) < 1e-9 # 1.345 + assert abs(blind - amounts["버림콘크리트"]) < 1e-9 # 0.145 + assert abs(form - amounts["유로폼"]) < 5e-4 # 3.2047 → 3.205 + assert abs(pipe - amounts["물구멍관"]) < 1e-9 # 0.32 (제원 칸 비었을 때) + assert footing["thickness_m"] + wall["height_m"] == 2.0 + + +def _wall_table(length: float = 10.0, rubble=None, **options) -> dict: + from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table + + structure = { + "structure_id": "w1", + "type_id": "retaining_wall", + "start_m": 0.0, + "end_m": length, + "options": {"form": "반중력식", "height_m": 2.0, "length_m": length, **options}, + } + table = build_table([structure], {"retaining_wall": "옹벽"}, rubble_base_thickness_m=rubble) + return table["structures"][0] + + +def _amounts(structure: dict) -> dict: + return {row["name"]: row for row in structure["components"]} + + +def test_옹벽_터파기는_기초_폭과_양쪽_여유_옆면_1대0점5이고_토공으로만_간다() -> None: + wall = _wall_table() + rows = _amounts(wall) + # 밑폭 1.6 + 0.3×2 = 2.2 · 깊이 0.4 + 0.1 + 0.2 = 0.7 · 옆면 1:0.5 → 윗폭 2.9 + # (2.2 + 2.9) ÷ 2 × 0.7 = 1.785/m + assert abs(rows["터파기"]["amount"] - 17.85) < 1e-9 # 수직일 때 15.4(2026-09-14 판정으로 고침) + # 든 것 = 기초·전단키 0.745 + (버림 + 잡석) 1.45 × 0.3 = 1.18 → 되메우기 0.605/m + assert abs(rows["잔토처리"]["amount"] - 11.8) < 1e-9 + assert abs(rows["되메우기"]["amount"] - 6.05) < 1e-9 # 수직일 때 3.6 + assert {rows[k]["destination"] for k in ("터파기", "되메우기", "잔토처리")} == {"earthwork"} + assert abs(rows["기초잡석"]["amount"] - 2.9) < 1e-9 # 원문 버림 0.145 × 2 + assert wall["trench_depth_m"] == 0.7 + assert "1:0.5" in rows["터파기"]["basis"] and "지반선" in rows["터파기"]["basis"] + # 잡석을 얇게 두면 전단키가 그 밑으로 나와 그 몫을 더 팜: (2.2+2.8)/2×0.6 + 0.35×0.1 + thin = _amounts(_wall_table(rubble=0.1)) + assert abs(thin["터파기"]["amount"] - 15.35) < 1e-9 + + +def test_옹벽_물구멍관은_제원_칸이_닿고_돌쌓기와_같은_규격으로_합쳐진다() -> None: + plain = _amounts(_wall_table())["물구멍관"] + assert plain["spec"] == "Ø50" and abs(plain["amount"] - 3.2) < 1e-9 + assert plain["basis_kind"] == "observed" and "관측값" in plain["basis"] + given = _amounts(_wall_table(weep_hole_area_m2=2.5, weep_hole_diameter_mm=75))["물구멍관"] + assert given["spec"] == "Ø75" and abs(given["amount"] - 1.6 / 2.5 * 0.4 * 10) < 1e-9 + + +def test_옹벽_그림은_기초잡석_두께를_산출_조건에서_받고_사유는_표와_같다() -> None: + shapes = build_figure(_wall_sheet(), rubble_thickness_m=0.3) + texts = _texts(shapes) + assert "기초잡석 T=0.3 m — 산출 조건 · 폭은 버림과 같음" in texts + assert "버림 T=0.10 · 폭 1.80 − 키 0.35 = 1.45 m" in texts + wall = _paths(shapes)[0]["points"] + assert [0.95, 0.4] in wall and [1.068, 2.0] in wall and [1.368, 2.0] in wall + # 표 사유와 같은 말 — 터파기를 어떻게 셌나(비탈분 제외) + _rows, table_notes = expand_observed( + "retaining_wall", + {"form": "반중력식", "height_m": 2.0}, + _wall_sheet() | {"options": {"length_m": 10}}, + ) + trench = [note for note in table_notes if "터파기" in note] + assert trench and trench[0] in texts and "지반선" in trench[0] + # 잡석 0.3 → 깊이 0.8 · 한쪽 0.4 + assert ( + "터파기 밑폭 2.20 · 윗폭 3.00 × 깊이 0.80 m (기초 1.6 + 여유 0.3×2 · 옆면 1:0.5)" in texts + ) + assert not any(text.startswith("기초잡석") for text in _texts(build_figure(_wall_sheet(), 0))) + + +def test_옹벽_못_그리는_장은_표와_같은_까닭() -> None: + assert "형식" in figure_reason({**_wall_sheet(), "options": {"height_m": 2.0}}) + other = figure_reason(_wall_sheet(height_m=1.6) | {"height_m": 1.6}) + assert other.startswith("그림 없음 — ") and "자료에 없습니다" in other + label = [t for t in _texts(build_figure(_wall_sheet(weep_hole_area_m2=3))) if "물구멍" in t] + assert label == ["물구멍 Ø50 — 3㎡당 1개소 · 관 0.4 m(관측값)"] + + +def test_양식_장은_양식이_푼_뒷길이로_그린다() -> None: + sheet = {**_sheet(), "formula_sheet": {"vars": {"L3": 55}}} + texts = _texts(build_figure(sheet)) + assert "뒷길이 ℓ₃ = 55㎝ (기본)" in texts and "상부 0.85 m" in texts diff --git a/resources/tester/test_b08_structure_figure_observed.py b/resources/tester/test_b08_structure_figure_observed.py new file mode 100644 index 00000000..eb81ad50 --- /dev/null +++ b/resources/tester/test_b08_structure_figure_observed.py @@ -0,0 +1,113 @@ +"""구조물도 그림 — 집수정 · 날개벽 · 물넘이포장 (관측 원단위, 2026-09-14 · 표 = 그림). + +⚠ 집수정 □형 Ø800 — 그림 치수(`section`)로 **표 수량이 원문 식 그대로** 다시 나와야 함. + 원문 값으로 바꿈: 콘크리트 2.84 → 2.83752 · 합판거푸집 21.28 → 21.2843 · + 철근 4.78 → 4.776 · 면목 12.67 → 12.6728. +""" + +from __future__ import annotations + +import math +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_ObservedUnit import load_observed_table # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_StructureFigure import attach_figures # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_StructureSheet import build_standard_sheets # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402 + +BASIN_SPEC = { + "inlet_basin_form": "□형(기본형)", + "inlet_basin_material": "콘크리트", + "pipe_diameter_mm": "800", +} + + +def _sheets(**options) -> list[dict]: + pipe = { + "structure_id": "p1", + "type_id": "pipe", + "start_m": 0.0, + "end_m": 0.0, + "options": {"pipe_kind": "흄관", **options}, + } + payload = build_standard_sheets(build_table([pipe], None)) + attach_figures(payload) + return payload["sheets"] + + +def _sheet(sheets: list[dict], type_id: str) -> dict: + return next(sheet for sheet in sheets if sheet["type_id"] == type_id) + + +def test_집수정_치수로_표_수량이_원문_식대로_다시_나온다() -> None: + entry = load_observed_table().find("pipe_inlet_basin", BASIN_SPEC) + s = entry["section"] + amounts = {item["name"]: item["amount"] for item in entry["components"]} + inner, wall, base = s["inner"], s["wall_m"], s["base_m"] + ol, ow, h = inner["length_m"] + 2 * wall, inner["width_m"] + 2 * wall, inner["height_m"] + il, iw = inner["length_m"], inner["width_m"] + sp = s["spillway"] + notch = (sp["top_m"] + sp["bottom_m"]) / 2 * sp["depth_m"] + hole = 3.14 * (s["pipe_d_m"] / 2) ** 2 # 원문이 3.14 로 셈 + concrete = ol * ow * h - il * iw * h - sp["count"] * notch * wall - hole * wall + ol * ow * base + slant = math.hypot((sp["top_m"] - sp["bottom_m"]) / 2, sp["depth_m"]) + form = ( + (ol * h - hole) + + (ol * h - notch) + + (ow * h - notch) * 2 + + (il * h - hole) + + (il * h - notch) + + (iw * h - notch) * 2 + + (ol + ow) * base * 2 + + slant * wall * sp["count"] * 2 + ) + chamfer = ol + il * 2 + slant * sp["count"] + sp["bottom_m"] * sp["count"] + wall * 4 + trench = (ol + s["dig"]["extra_length_m"]) * s["dig"]["width_m"] * (h + base) + backfill = trench - il * iw * (h + base) + assert abs(concrete - amounts["콘크리트"]) < 1e-9 # 2.83752 + assert abs(form - amounts["합판거푸집"]) < 5e-5 # 21.28432 + assert abs(chamfer - amounts["면목"]) < 5e-5 # 12.67279 + assert abs(trench - amounts["터파기"]) < 1e-9 + assert abs(backfill - amounts["되메우기"]) < 1e-9 + assert abs(trench - backfill - amounts["잔토처리"]) < 1e-9 + + +def test_집수정_그림이_서고_날개벽은_까닭() -> None: + sheets = _sheets(wing_wall_type="A-TYPE", **BASIN_SPEC) + basin = _sheet(sheets, "pipe_inlet_basin") + texts = [s["text"] for s in basin["figure"] if s["kind"] == "text"] + assert "안 3×1×1.2 · 벽 0.2 · 바닥기초 3.4×1.4×0.2 m" in texts + assert "터파기 3.8 × 2 × 깊이 1.4 m" in texts + wing = _sheet(sheets, "pipe_wing_wall") + assert ( + wing["rows"] and wing["figure"] is None and "구조도 원본 확보 대기" in wing["figure_reason"] + ) + + +def test_돌집수정은_치수_원문이_없어_까닭() -> None: + basin = _sheet( + _sheets(pipe_diameter_mm="800", inlet_basin_form="돌집수정 ㄷ형"), "pipe_inlet_basin" + ) + assert basin["rows"] and basin["figure"] is None and "집계표 관측값" in basin["figure_reason"] + + +def test_물넘이포장_그림과_표에_원문_차이_사유가_같다() -> None: + structure = { + "structure_id": "f1", + "type_id": "ford_pavement", + "start_m": None, + "end_m": None, + "options": {"ford_width_m": 5, "length_m": 10, "thickness_cm": 20}, + } + payload = build_standard_sheets(build_table([structure], None)) + attach_figures(payload) + sheet = payload["sheets"][0] + texts = [s["text"] for s in sheet["figure"] if s["kind"] == "text"] + diff = [note for note in sheet["notes"] if "원문 둘이 다름" in note] + assert diff and diff[0] in texts + assert "와이어메쉬 — 밑에서 0.133 m (2/3T)" in texts diff --git a/resources/tester/test_b08_structure_figure_small.py b/resources/tester/test_b08_structure_figure_small.py new file mode 100644 index 00000000..18c6aafa --- /dev/null +++ b/resources/tester/test_b08_structure_figure_small.py @@ -0,0 +1,111 @@ +"""구조물도 그림 — 기슭막이·골막이·떼흙막이·바닥막이·개거 (2026-09-14 · 표 = 그림). + +표를 세운 그 전개(`build_table`)로 장을 만들고 그림 글자가 **표 근거와 같은 수**를 보이는지 대조. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_StructureFigure import attach_figures # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_StructureSheet import build_standard_sheets # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import ( # noqa: E402 + BED_SILL_TRENCH_NOTE, + SOIL_GUARD_SOD, +) + + +def _sheet(type_id: str, rubble=None, **options) -> dict: + structure = { + "structure_id": type_id, + "type_id": type_id, + "start_m": 0.0, + "end_m": float(options.get("length_m") or 0.0), + "options": options, + } + table = build_table([structure], None, rubble_base_thickness_m=rubble) + payload = build_standard_sheets(table) + attach_figures(payload, rubble) + return payload["sheets"][0] + + +def _texts(sheet: dict) -> list[str]: + return [shape["text"] for shape in sheet["figure"] or [] if shape["kind"] == "text"] + + +def _basis(sheet: dict, name: str) -> str: + return next(row["basis"] for row in sheet["rows"] if row["name"] == name) + + +def test_기슭막이_돌쌓기형은_돌쌓기_그림이고_기울기_두께가_표와_같다() -> None: + sheet = _sheet("revetment", form="돌쌓기(메)", height_m=2.0, back_len_cm=35, length_m=10) + texts = _texts(sheet) + assert sheet["figure_reason"] is None + slope = re.search(r"1:([\d.]+)", _basis(sheet, "돌쌓기")).group(1) # 메쌓기 표준경사 + assert f"1 : {slope}" in texts + top = re.search(r"상부 ([\d.]+)", _basis(sheet, "입적")).group(1) + assert f"상부 {top} m" in texts + concrete = _sheet("revetment", form="콘크리트", height_m=2.0, length_m=10) + assert concrete["figure"] is None and "콘크리트 기슭막이" in concrete["figure_reason"] + + +def test_골막이_그림_치수는_표_두께식과_같다() -> None: + sheet = _sheet( + "erosion_check", form="돌", height_m=1.5, top_length_m=4, bottom_length_m=2, back_len_cm=45 + ) + texts = _texts(sheet) + thickness = re.search(r"평균두께 ([\d.]+)m", _basis(sheet, "입적")).group(1) + label = next(t for t in texts if t.startswith("상부")) + assert f"평균 {float(thickness):.2f}" in label + trench = next(t for t in texts if t.startswith("바닥파기")) + width = re.search(r"× ([\d.]+) \)", _basis(sheet, "터파기").replace(")", " )")).group(1) + assert f"폭 {float(width):.2f}" in trench + band = [s for s in sheet["figure"] if s["kind"] == "path" and s["dash"]][0] + assert min(y for _x, y in band["points"]) < 0 # 바닥파기 띠가 밑변 바깥(아래)에 섬 + + +def test_떼흙막이_붙박이_치수로_표_두_줄이_다시_나온다() -> None: + d = SOIL_GUARD_SOD["dims"] + sod = ( + d["top_m"] * d["sod_m"] + + d["bottom_m"] * d["sod_m"] + + (d["top_m"] + d["bottom_m"]) / 2 * d["height_m"] + + d["slant_m"] * d["sod_m"] * 2 + ) + trench = (d["slant_m"] * d["trench_width_m"] * d["trench_depth_m"] * 2) + ( + d["bottom_m"] * d["trench_width_m"] * d["trench_depth_m"] + ) + rows = {row[0]: row[2] for row in SOIL_GUARD_SOD["rows"]} + assert round(sod, 2) == rows["떼"] and round(trench, 2) == rows["터파기"] + assert ( + abs(((d["top_m"] - d["bottom_m"]) / 2) ** 2 + d["height_m"] ** 2 - d["slant_m"] ** 2) < 0.01 + ) + sheet = _sheet("soil_guard", form="떼", height_m=0.5, length_m=10) + assert sheet["figure"] and sheet["unit_label"] == "개소당" + other = _sheet("soil_guard", form="돌(찰)", height_m=0.5, length_m=10) + assert other["figure"] is None and "떼흙막이" in other["figure_reason"] + + +def test_바닥막이_그림_사유는_표와_같은_말이고_기초잡석은_산출_조건() -> None: + sheet = _sheet("bed_sill", rubble=0.3, form="돌붙임(찰)", area_m2=12, height_m=0.3) + texts = _texts(sheet) + assert BED_SILL_TRENCH_NOTE in sheet["notes"] and BED_SILL_TRENCH_NOTE in texts + assert "기초잡석 T=0.3 m — 산출 조건" in texts + dry = _sheet("bed_sill", form="돌붙임(메)", area_m2=12, height_m=0.3) + assert ( + not any("기초잡석" in t for t in _texts(dry)) and BED_SILL_TRENCH_NOTE not in dry["notes"] + ) + + +def test_개거는_표가_서도_치수를_못_정한_까닭() -> None: + sheet = _sheet("open_ditch", ditch_spec="콘크리트 개거 150×200", length_m=5) + assert sheet["rows"] and sheet["figure"] is None and "확인 대기" in sheet["figure_reason"] + l_type = _sheet("open_ditch", ditch_spec="L형수로 H=0.2", length_m=5) + assert l_type["rows"] and "단면 그림이 없어" in l_type["figure_reason"] diff --git a/resources/tester/test_b08_structure_library.py b/resources/tester/test_b08_structure_library.py index 10f4b4aa..5a6acf0c 100644 --- a/resources/tester/test_b08_structure_library.py +++ b/resources/tester/test_b08_structure_library.py @@ -216,6 +216,26 @@ def test_확정_때_기본_양식을_박는다(project: Path) -> None: assert library_module.pin_program_templates(project) == 0 # 이미 박힌 것은 그대로 +def test_항목마다_양식형_고정형_종류가_붙는다(tmp_path: Path) -> None: + """PLAN 4장 「항목마다 종류 배지」 — 명세 13장: 칸은 같고 `formula` 유무로만 갈림.""" + folder = tmp_path / "personal" + folder.mkdir() + fixed = { + "type_id": "masonry_wet", + "code": "AX-ST-0000beef", + "name": "뽑은 항목", + "rows": [{"name": "돌쌓기", "formula": "", "amount": 20.9}], + } + (folder / "AX-ST-0000beef.json").write_text(json.dumps(fixed), encoding="utf-8") + dirs = {"personal": folder, "program": library_module.TEMPLATE_DIR} + kinds = {item["tier"]: item["kind"] for item in library_module.list_items(dirs, "masonry_wet")} + assert kinds == {"personal": "fixed", "program": "form"} + ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet_Library.ts").read_text( + encoding="utf-8" + ) + assert "양식형" in ui and "고정형" in ui and "item.kind" in ui + + def test_코드_모양이_아니면_박지_않는다(project: Path) -> None: with pytest.raises(ValueError): library_module.import_item(project, {"type_id": "masonry_wet", "code": "../x"}, "personal") diff --git a/resources/tester/test_b08_structure_table_holes.py b/resources/tester/test_b08_structure_table_holes.py new file mode 100644 index 00000000..5b6363f2 --- /dev/null +++ b/resources/tester/test_b08_structure_table_holes.py @@ -0,0 +1,98 @@ +"""구조물도 그림을 그리려다 드러난 **표 구멍** (2026-09-14 · PLAN 12장 남은 종류 그림). + +⚠ 셋 — 값이 서기는 하거나(떼흙막이) 조용히 0 이라 아무 시험도 안 잡던 자리. + ① 골막이·바닥막이는 개소당·㎡당 값인데 내역 단위가 「m · 연장 0」 → **수량 0, 내역에 안 섬** + ② 떼흙막이는 개소당 값이 연장으로 나뉘어 **떼 0.139/m** 로 보였음 + ③ 개거 「L형수로 H=0.2」 — 등록부 선택지 글자와 엔진 키가 달라 골라도 영영 안 섬 + ④ 기슭막이 사유 「돌 종류·뒷길이 칸이 없어」 — 칸이 선 뒤로 거짓 · 같은 말 두 번 +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_StructureSheet import build_standard_sheets # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402 + + +def _table(type_id: str, start: float, end: float, **options) -> dict: + structure = { + "structure_id": type_id, + "type_id": type_id, + "start_m": start, + "end_m": end, + "options": options, + } + return build_table([structure], None) + + +def _sheet(table: dict) -> dict: + return build_standard_sheets(table)["sheets"][0] + + +def _unit(sheet: dict, name: str) -> float: + return next(row["unit_amount"] for row in sheet["rows"] if row["name"] == name) + + +def test_골막이는_개소당으로_서고_내역_수량이_1_개소() -> None: + table = _table( + "erosion_check", 50, 50, form="돌", height_m=1.5, top_length_m=4, bottom_length_m=2 + ) + item = table["structures"][0] + assert (item["billing_unit"], item["billing_quantity"]) == ("개소", 1.0) + sheet = _sheet(table) + assert sheet["unit_label"] == "개소당" and sheet["unpriced_rows"] == [] + row = next(r for r in build_handoff(unit_quantity_table=table)["work_items"] if r["unit"]) + assert (row["unit"], row["quantity"]) == ("개소", 1.0) # 전: m · 0.0 → 내역에 안 섬 + + +def test_떼흙막이는_개소당_값이_그대로_보인다() -> None: + sheet = _sheet(_table("soil_guard", 60, 70, form="떼", height_m=0.5, length_m=10)) + assert sheet["unit_label"] == "개소당" + assert abs(_unit(sheet, "떼") - 1.39) < 1e-9 # 전: 0.139 (m당으로 나뉨) + + +def test_바닥막이는_제곱미터당() -> None: + table = _table("bed_sill", 80, 80, form="돌붙임(찰)", area_m2=12, height_m=0.3) + sheet = _sheet(table) + assert sheet["unit_label"] == "㎡당" and sheet["billing_total"] == 12 + assert abs(_unit(sheet, "돌붙임") - 1.0) < 1e-9 and abs(_unit(sheet, "입적") - 0.45) < 1e-9 + + +def test_개거_L형수로는_등록부_글자로_선다() -> None: + registry = json.loads( + (ROOT / "B05_Profile" / "B05_Profile_Structure_Types.json").read_text(encoding="utf-8") + ) + choices = next( + option["choices"] + for item in registry["types"] + if item["type_id"] == "open_ditch" + for option in item["options"] + if option["key"] == "ditch_spec" + ) + for choice in choices: + item = _table("open_ditch", 0, 0, ditch_spec=choice, length_m=5)["structures"][0] + assert item["components"], (choice, item["notes"]) + concrete = next( + c + for c in _table("open_ditch", 0, 0, ditch_spec="L형수로 H=0.2", length_m=5)["structures"][ + 0 + ]["components"] + if c["name"] == "콘크리트" + ) + assert abs(concrete["amount"] - 0.1508 * 5) < 1e-9 + + +def test_기슭막이_사유에_없는_칸_이야기가_없다() -> None: + notes = _table("revetment", 0, 10, form="돌쌓기(찰)", height_m=2, length_m=10)["structures"][0][ + "notes" + ] + assert not any("칸이 없어" in note for note in notes) + assert any("뒷길이를 안 골라" in note for note in notes) # 같은 사실은 돌쌓기 한 줄이 말함 diff --git a/resources/tester/test_b08_structure_template_parity.py b/resources/tester/test_b08_structure_template_parity.py index 0fc802b8..98a61a31 100644 --- a/resources/tester/test_b08_structure_template_parity.py +++ b/resources/tester/test_b08_structure_template_parity.py @@ -61,7 +61,16 @@ CASES = [ dict(height=2.0, length=7.5, back_len_cm=45, face_slope_ratio=0.35, foundation="기초유"), dict(height=2.0, length=10.0, back_len_cm=55, thickness_top_m=0.9, foundation="기초버림"), dict(height=2.0, length=10.0, back_len_cm=45, thickness_bottom_m="1.4", stone_kind="깬잡석"), - dict(height=2.0, length=10.0, stone_kind="야면석·호박돌", stone_coeff_basis="실무 관행"), + # 뒷길이 45 — 안 고르면 표준 하한 30㎝ 라 야면석 관측 무게(35·45·55)가 없어 양식만 오류 줄(명세 13장 Ⓐ) + dict( + height=2.0, + length=10.0, + back_len_cm=45, + stone_kind="야면석·호박돌", + stone_coeff_basis="실무 관행", + ), + # 뒷길이 안 고름 — 전개·양식이 같은 표준 하한(찰 ≤3m 30㎝, 브레인 판정 ㉮) + dict(height=2.0, length=10.0, stone_kind="깬돌", foundation="기초유"), dict(height=1.5, length=10.0, back_len_cm=35, weep_hole_area_m2=3, foundation="기초유"), dict(height=2.5, length=12.0, back_len_cm=60, stone_kind="견치돌", rubble=0.3), # 줄이 안 서는 갈래 — `when` diff --git a/resources/tester/test_b08_tree_waste.py b/resources/tester/test_b08_tree_waste.py new file mode 100644 index 00000000..62ce3dd8 --- /dev/null +++ b/resources/tester/test_b08_tree_waste.py @@ -0,0 +1,79 @@ +"""임목폐기물 처리 — 톤으로 서는가 · 경비라 내역에 안 서는가 (2026-09-14 판정). + +⚠ 산식 — 한국건설기술연구원(2012) 3.2: WA = 0.5 × π × (B/2)² × h × 1.3 × W1 × N · WR = WA × 15/85. +⚠ 실정보고 예(p26) 「V1 = 0.5×π×(0.16/2)²×10.0×1.3×134/1000×7,450 = 130.40㎥」 로 + 지상부 부피를 대 봄. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_Preparation_TreeWaste import ( # noqa: E402 + tree_waste_rows, + tree_waste_tons, +) + +SLOPE = {"tree_removal_fill": 4000.0, "tree_removal_cut": 3450.0} # 7,450㎡ +FIELD = {"stems_per_1000m2": 134, "dbh_cm": 16, "height_m": 10.0, "unit_weight_kg_m3": 1000} + + +def test_지상부는_실정보고_부피와_같다() -> None: + """W1 = 1,000㎏/㎥ 로 두면 지상부 톤 = 지상부 부피(㎥) — 실정보고 130.40㎥.""" + above, root = tree_waste_tons(7450.0, FIELD) + # 지침 부록1 식·실정보고 모두 π 를 3.14 로 적음 — 그대로 셈해 130.40 과 같다. + assert above == pytest.approx(130.40, abs=0.01) + assert root == pytest.approx(above * 15 / 85) + + +def test_칸이_비면_톤이_안_서고_무엇이_빈지_적는다() -> None: + row = tree_waste_rows(SLOPE, {**FIELD, "height_m": None})[0] + assert row["amount"] is None and "평균 수고" in row["reason"] + assert row["reference_amount"] == 7450.0 + + +def test_단가가_없으면_금액이_안_서고_있으면_미확정() -> None: + no_price = tree_waste_rows(SLOPE, FIELD)[0] + assert no_price["unit"] == "톤" and no_price["amount"] > 0 + assert no_price["amount_krw"] is None and no_price["unconfirmed"] == 0 + priced = tree_waste_rows(SLOPE, FIELD, 60000)[0] + assert priced["unconfirmed"] == 1 + assert priced["amount_krw"] == int(priced["amount"] * 60000) + + +def test_톤_경계를_알리고_분리발주_대상_아님을_적는다() -> None: + big = tree_waste_rows(SLOPE, FIELD)[0] # 약 153톤 + assert "분리발주 대상 아님" in big["reason"] and "환경부" in big["reason"] + small = tree_waste_rows({"tree_removal_fill": 500.0}, FIELD)[0] # 약 10톤 + assert 5 <= small["amount"] < 100 and "5톤 이상" in small["reason"] + + +def test_경비라_내역에_안_서고_제외_사유가_간다() -> None: + from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff + from B08_Quantity.B08_Quantity_Engine_Preparation import build_table + + table = build_table(SLOPE, tree_waste=FIELD, tree_waste_unit_price_krw_per_ton=60000) + handed = {r["name"]: r for r in build_handoff(preparation_table=table)["work_items"]} + row = handed["임목폐기물 처리"] + assert row["in_bill"] is False and row["blocked_kind"] is None + assert "경비" in row["in_bill_reason"] + + +def test_뿌리분_체적법은_D_세제곱_곱하기_1300() -> None: + """지침 부록1 — V = 0.3927 D³ · 뿌리분 중량 = V × 1,300㎏/㎥ (본수만큼).""" + field = {**FIELD, "root_ball_diameter_m": 0.5} + above, root = tree_waste_tons(7450.0, field, "root_ball") + stems = 134 / 1000 * 7450 + assert root == pytest.approx(0.3927 * 0.5**3 * 1300 * stems / 1000) + assert above == pytest.approx(tree_waste_tons(7450.0, FIELD)[0]) + + +def test_체적법인데_뿌리분_직경이_비면_안_선다() -> None: + row = tree_waste_rows(SLOPE, FIELD, root_method="root_ball")[0] + assert row["amount"] is None and "뿌리분 직경" in row["reason"] diff --git a/resources/tester/test_b08_unit_quantity.py b/resources/tester/test_b08_unit_quantity.py index 8e7249ef..3ad095f4 100644 --- a/resources/tester/test_b08_unit_quantity.py +++ b/resources/tester/test_b08_unit_quantity.py @@ -125,7 +125,8 @@ def test_실무_시트_m당_값_재현() -> None: 품셈 1-2-2 의 소수 자리는 표기 규칙이고 계산은 전정밀로 둔다(PLAN 8-16). 맞추려고 식을 비틀지 않는다 — 허용오차를 그 차이만큼 둔다. """ - result = expand(돌쌓기찰(height=1.5, length=1.0)) + # 실무 시트가 뒷길이 45 — 2026-09-14 부터 안 고르면 표준 하한(찰 ≤1.5m 25㎝)이라 고름. + result = expand(돌쌓기찰(height=1.5, length=1.0, back_len_cm=45)) assert result.height_m == 1.5 assert result.length_m == 1.0 assert 성분(result, "터파기").amount == pytest.approx(1.55, abs=0.02) @@ -135,7 +136,7 @@ def test_실무_시트_m당_값_재현() -> None: def test_평균두께는_전정밀로() -> None: """실무가 0.83 으로 반올림해 재계산한 자리 — 우리는 0.825 를 그대로 쓴다.""" - result = expand(돌쌓기찰(height=1.5, length=1.0)) + result = expand(돌쌓기찰(height=1.5, length=1.0, back_len_cm=45)) # 터파기 = 높이 × (평균두께 + 0.2) × 연장 = 1.5 × 1.025 = 1.5375 assert 성분(result, "터파기").amount == pytest.approx(1.5375) diff --git a/resources/tester/test_b08_wall_height_unconfirmed.py b/resources/tester/test_b08_wall_height_unconfirmed.py new file mode 100644 index 00000000..0b375b30 --- /dev/null +++ b/resources/tester/test_b08_wall_height_unconfirmed.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +"""높이 없이 놓인 벽 — **놓기는 되고 줄은 서되 미확정(금액 밖)** (2026-09-14 브레인 판정 ①). + +앞서 B05 폼이 C군 높이가 비면 놓기를 막았음. 우리 규칙 「줄은 서고 금액은 안 섬」과 어긋나 풀고, +높이가 물량 밑수인 벽(옹벽·돌쌓기 찰/메·큰돌쌓기)은 B08 표에서 미확정으로 뜨게 함. +흙막이(떼 개소당)는 높이를 안 써 그대로. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Base import ( # noqa: E402 + UNCONFIRMED_NO_HEIGHT, + missing_height_reason, +) + +COMMIT = (ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Panel_Commit.ts").read_text( + encoding="utf-8" +) + + +def _wall(type_id: str = "masonry_wet", **options) -> dict: + return { + "structure_id": "w", + "type_id": type_id, + "start_m": 0.0, + "end_m": 10.0, + "options": { + "length_m": 10.0, + "back_len_cm": 45, + "stone_kind": "깬돌", + "foundation": "기초유", + **options, + }, + } + + +def test_높이_없는_돌쌓기는_미확정으로_선다() -> None: + table = build_table([_wall()], {"masonry_wet": "돌쌓기(찰)"}, {}) + row = table["structures"][0] + assert row["unconfirmed"] == UNCONFIRMED_NO_HEIGHT + + +def test_높이를_적으면_미확정이_아니다() -> None: + table = build_table([_wall(height_m=2.0)], {"masonry_wet": "돌쌓기(찰)"}, {}) + assert table["structures"][0]["unconfirmed"] == "" + + +def test_높이를_안_쓰는_종류는_그대로() -> None: + assert missing_height_reason(_wall("soil_guard")) == "" + for type_id in ("retaining_wall", "masonry_dry", "boulder_masonry"): + assert missing_height_reason(_wall(type_id)) == UNCONFIRMED_NO_HEIGHT, type_id + assert missing_height_reason(_wall(type_id, height_m="1.5")) == "", type_id + + +def test_폼이_높이_빈칸으로_놓기를_안_막는다() -> None: + assert "height?.isEmpty()" not in COMMIT diff --git a/resources/tester/test_b09_bill_sheet_view.py b/resources/tester/test_b09_bill_sheet_view.py new file mode 100644 index 00000000..4dbfbbc6 --- /dev/null +++ b/resources/tester/test_b09_bill_sheet_view.py @@ -0,0 +1,98 @@ +"""B09 내역서 화면 자료 — 화면이 곱하거나 더하지 않게 서버가 싣는 칸 (PLAN 12장 1차). + +겨누는 것 + ① 내역 줄 성분 단가 × 수량 = 성분 금액(성분마다 절사) — 화면은 실린 값만 찍음 + ② 머리글 줄 금액 = 아래 줄 금액의 합(성분마다) · 본체 합계엔 안 들어감 + ③ 일위대가 호표 번호 — 내역에 처음 쓰인 차례 · 같은 호표는 한 번 · 안에서 부르는 호표는 뒤에 + ④ 단산 목록 — 부르는 일위대가 전부 · 성분 금액 +""" + +from __future__ import annotations + +from decimal import Decimal +from functools import lru_cache + +from B09_Estimation.B09_Estimation_BillOfQuantities import bill_line, build_bill +from B09_Estimation.B09_Estimation_PriceBook import ( + Money3, + PriceBook, + PriceDetail, + PriceKind, + PriceTitle, +) +from B09_Estimation.B09_Estimation_UnitPrice import _slots, build_unit_prices +from B09_Estimation.B09_Estimation_UnitPriceSheet import build_unit_price_sheet + + +@lru_cache(maxsize=1) +def _bill(): + payload = { + "work_items": [ + {"work_item_code": "FP-09-03-02", "name": "토사깍기", "unit": "㎥", "quantity": 123.4}, + {"work_item_code": "FP-09-12-01", "name": "측구터파기", "unit": "㎥", "quantity": 7}, + {"work_item_code": "FP-09-03-02", "name": "토사깍기", "unit": "㎥", "quantity": 5}, + ], + "materials": [], + } + return build_bill(payload, build=build_unit_prices()) + + +def test_줄_성분_단가와_금액이_실린다() -> None: + priced = [r for r in _bill().rows if not r.is_group and r.amount_krw is not None] + assert priced + for row in priced: + unit = Money3(row.unit_material_krw, row.unit_labor_krw, row.unit_expense_krw) + line = bill_line(unit, row.quantity) + assert (line.material, line.labor, line.expense) == ( + row.material_krw, + row.labor_krw, + row.expense_krw, + ) + assert row.amount_krw == line.total + body = row.as_dict() + assert body["price_code"] == row.price_code and body["unit_labor_krw"] is not None + + +def test_머리글_줄은_아래_줄_합이고_본체엔_안_든다() -> None: + bill = _bill() + detail_total = sum(r.amount_krw or 0 for r in bill.rows if not r.is_group) + assert bill.body_total_krw == detail_total + for group in (r for r in bill.rows if r.is_group): + children = [ + r + for r in bill.rows + if not r.is_group and r.item_no.startswith(f"{group.item_no}-") and r.amount_krw + ] + assert group.amount_krw == sum(r.amount_krw for r in children) + assert group.labor_krw == sum(r.labor_krw for r in children) + + +def test_호표_번호는_처음_쓰인_차례이고_안에서_부르는_호표는_뒤에() -> None: + book = PriceBook() + book.add_title(PriceTitle("L", PriceKind.LABOR, "보통인부", slots=_slots(Decimal(100000)))) + for code in ("B-A", "B-B", "B-C"): + book.add_title(PriceTitle(code, PriceKind.UNIT_PRICE, code)) + book.add_detail(PriceDetail("B-C", "L", Decimal(1))) + book.add_detail(PriceDetail("B-A", "B-C", Decimal(2))) # A 안에서 C 를 부름 + book.add_detail(PriceDetail("B-B", "L", Decimal(1))) + + class Row: + def __init__(self, code: str) -> None: + self.price_code, self.name, self.spec, self.unit = code, code, "", "㎥" + + sheet = build_unit_price_sheet([Row("B-B"), Row("B-A"), Row("B-B"), Row("")], book) + assert [(e.number, e.code) for e in sheet.entries] == [(1, "B-B"), (2, "B-A"), (3, "B-C")] + assert sheet.entries[1].as_dict()["label"] == "제 2 호표" + assert sheet.entries[1].money.labor == 200000 + + +def test_내역에_호표_목록과_단산_목록이_함께_실린다() -> None: + bill = _bill() + codes = [e.code for e in bill.unit_price_sheet.entries] + used = list(dict.fromkeys(r.price_code for r in bill.rows if r.price_code)) + assert [c for c in codes if c in used] == used # 안에서 부르는 호표가 끼어도 차례는 그대로 + for entry in bill.price_basis.entries: + body = entry.as_dict() + assert body["unit_price_codes"] and Money3( + *(int(body[k]) for k in ("material_krw", "labor_krw", "expense_krw")) + ).total == int(body["unit_price_krw"].split(".")[0]) diff --git a/resources/tester/test_b09_chisel.py b/resources/tester/test_b09_chisel.py new file mode 100644 index 00000000..4e4d9d06 --- /dev/null +++ b/resources/tester/test_b09_chisel.py @@ -0,0 +1,85 @@ +"""치즐 소모량(Ⓐ-3 · 2026-09-14 브레인 판정) — **칸을 채우면 치즐 공종 상태가 움직이는가**로 잼. + +「칸이 섰다」가 아니라 「칸을 채우면 못 붙은 줄이 풀린다」가 판정(81공종 다시 셈에서 칸을 다 채워도 +상태가 안 움직였던 까닭이 치즐 15공종). 단가는 없음 — 시험의 223,000 원은 실무 원본(영월 M00118) +대조용 값이고 프로그램에 넣지 않음. + +근거 — 실무 원본 단가산출근거 「(2) 치즐 손료 · M=0.006 × 223,000 / Q = 382.2 W/㎥」(재료비) · +품셈 제8장 [주]⑤ 「브레이커 … 손료 및 치즐 소모율을 **추가**」(기계 손료에 안 듦 → 이중계상 아님). +""" + +from __future__ import annotations + +from decimal import Decimal + +from B09_Estimation.B09_Estimation_Chisel import CHISEL_CODE +from B09_Estimation.B09_Estimation_UnitPrice import cached_build + +#: 기본 조립에서 「치즐소모량」 이 못 붙은 줄로 남던 15공종. +CHISEL_ITEMS = ( + "FP-09-04-01", + "FP-09-05-02", + "FP-09-12-02", + "FP-09-12-03", + *(f"FP-09-13-{n:02d}" for n in range(7, 19)), +) +PRICE = (CHISEL_CODE, "223000", "시험") + + +def _chisel_labels(build, code: str) -> list[str]: + return [label for label in build.unattached.get(code, []) if "치즐" in label] + + +def test_칸을_안_채우면_종전대로_못_붙은_줄() -> None: + base = cached_build() + assert CHISEL_CODE not in base.book.titles + assert all(_chisel_labels(base, code) for code in CHISEL_ITEMS) + # 「자재 단가」 탭에 칸이 서는 자리 — 치즐을 쓰는 16공종이 다 실림(암질 표 둘 포함) + assert set(CHISEL_ITEMS) <= set(base.material_uses[CHISEL_CODE]) + + +def test_칸을_채우면_치즐_못_붙은_줄이_풀린다() -> None: + filled = cached_build(material_prices=(PRICE,)) + left = {code: _chisel_labels(filled, code) for code in CHISEL_ITEMS} + # 9-4-1 평균 갈래만 남음 — 원문 [주]① 은 Q 평균만 정하고 치즐 평균은 안 줌(지어내지 않음) + assert {code: labels for code, labels in left.items() if labels} == { + "FP-09-04-01": [left["FP-09-04-01"][0]] + } + assert "평균" in left["FP-09-04-01"][0] + assert "9-12-3" in left["FP-09-04-01"][0] and "0.018" in left["FP-09-04-01"][0] # 선례 + whole = [code for code in CHISEL_ITEMS if not filled.unattached.get(code)] + assert len(whole) >= 12, ( + whole + ) # 못 붙은 줄이 아예 없어진 공종(9-13-14·15 는 들어내기 줄이 남음) + + +def test_치즐은_시간당_소모량_나누기_Q_로_재료비에_붙음() -> None: + base = cached_build() + filled = cached_build(material_prices=(PRICE,)) + # 9-4-1 연암 — 0.006 본/hr ÷ Q 5.0 × 223,000 = 267.6 원/㎥ (배분율 없음) + gap = ( + filled.book.resolve("B-FP-09-04-01#연암").material + - base.book.resolve("B-FP-09-04-01#연암").material + ) + # 줄 0.1원·소계 원 미만 절사가 기존 재료비 끝전과 겹쳐 267 또는 268 + assert Decimal("267") <= gap <= Decimal("268") + rock = next(d for d in filled.book.details["D-FP-09-04-01#연암"] if d.ref_code == CHISEL_CODE) + assert rock.quantity == Decimal("0.006") / Decimal("5.0") and rock.output == Decimal("5.0") + # 9-12-2 암절취 — 장비(90%) 몫 · 0.006 ÷ 3.5 × 0.9 × 223,000 = 344.05 + detail = next(d for d in filled.book.details["D-FP-09-12-02"] if d.ref_code == CHISEL_CODE) + assert detail.quantity == Decimal("0.006") / Decimal("3.5") * Decimal("0.9") + assert detail.output == Decimal("3.5") + + +def test_치즐은_기계_손료와_겹치지_않음() -> None: + """㉠ 브레이커 시간당 사용료는 손료만(부착 장비) — 치즐이 X 층 안에 없음.""" + filled = cached_build(material_prices=(PRICE,)) + breaker = next(code for code in filled.book.titles if code.startswith("X-0230-")) + stack, seen = [breaker], set() + while stack: + code = stack.pop() + assert code != CHISEL_CODE + if code in seen: + continue + seen.add(code) + stack.extend(d.ref_code for d in filled.book.details.get(code, []) if d.ref_code != code) diff --git a/resources/tester/test_b09_contract.py b/resources/tester/test_b09_contract.py new file mode 100644 index 00000000..6199b6a6 --- /dev/null +++ b/resources/tester/test_b09_contract.py @@ -0,0 +1,211 @@ +"""계약 단계 — 설계 → 계약내역 **구조**가 서는가 (PLAN 12장 · 2026-09-14 브레인 배정). + +⚠ 값이 맞는지는 못 잰다 — 계약 표본 0건(STmate 27번 §9). 여기서 재는 것: + ① 적용률 100% 면 설계와 같음 · 설계 줄(입력)은 안 바뀜 + ② 노·재·경 따로 곱해짐 · 절사는 설계 내역 규칙(성분 단가 원 미만 · 줄 성분마다) + ③ 적용 제외 줄은 설계 단가 · W 코드 안 붙음 + ④ 0% = 0 원(브레인 판정) · 「공내역 생성」 켜면 그 줄에 공내역 이름 + ⑤ 동일코드 개별생성 켜면 줄마다 다른 W 코드 · 끄면 같은 코드 한 벌 + ⑥ 묶음 줄 합 · 틀린 적용률 거름 + ⑦ 2벌 — 기초단가 적용은 단가표 **복사본**만(설계 단가표 불변) · 못 푸는 줄은 까닭 · + 계약 호표(W-B·W-D)는 기초단가 적용과 함께만 섬 +""" + +from __future__ import annotations + +import copy +import sys +from decimal import Decimal +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B09_Estimation.B09_Estimation_Contract import clean_settings, contract_bill # noqa: E402 +from B09_Estimation.B09_Estimation_PriceBook import ( # noqa: E402 + PriceBook, + PriceDetail, + PriceKind, + PriceTitle, +) +from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild # noqa: E402 + +ROWS = [ + {"item_no": "1", "is_group": True, "in_bill": True}, + { + "item_no": "1.1", + "is_group": False, + "in_bill": True, + "quantity": "12.5", + "price_code": "B-FP-09-03-02", + "unit_material_krw": "1001", + "unit_labor_krw": "2003", + "unit_expense_krw": "3005", + }, + { + "item_no": "1.2", + "is_group": False, + "in_bill": True, + "quantity": "3", + "price_code": "B-FP-09-03-02", + "unit_material_krw": "1001", + "unit_labor_krw": "2003", + "unit_expense_krw": "3005", + }, + { + "item_no": "1.3", + "is_group": False, + "in_bill": True, + "quantity": "2", + "price_code": "M-관급자재", + "unit_material_krw": "50000", + "unit_labor_krw": "0", + "unit_expense_krw": "0", + }, +] + + +def _rows(settings: dict) -> dict[str, dict]: + return {row["item_no"]: row for row in contract_bill(ROWS, settings)["rows"]} + + +def test_100퍼센트면_설계와_같고_입력은_안_바뀐다() -> None: + before = copy.deepcopy(ROWS) + result = contract_bill(ROWS, clean_settings({})[0]) + assert ROWS == before + assert result["totals"]["design"] == result["totals"]["contract"] + + +def test_노재경을_따로_곱하고_성분_단가를_원_미만_절사() -> None: + row = _rows({"labor_pct": "80", "material_pct": "90", "expense_pct": "70"})["1.1"] + assert ( + row["contract_unit_material_krw"], + row["contract_unit_labor_krw"], + row["contract_unit_expense_krw"], + ) == ("900", "1602", "2103") # 900.9→900 · 1602.4→1602 · 2103.5→2103 + assert row["contract_material_krw"] == "11250" # 12.5 × 900 + assert row["contract_code"] == "W-B-FP-09-03-02" + + +def test_적용_제외_줄은_설계_단가_W코드_없음() -> None: + row = _rows({"material_pct": "50", "excluded": ["1.3"]})["1.3"] + assert row["contract_unit_material_krw"] == "50000" and row["contract_excluded"] is True + assert not row["contract_code"].startswith("W-") + + +def test_0퍼센트_공내역_옵션() -> None: + zero = {"labor_pct": "0", "material_pct": "0", "expense_pct": "0"} + empty = _rows({**zero, "zero_makes_empty": True})["1.1"] + assert empty["contract_amount_krw"] == "0" and "공내역" in empty["contract_note"] + plain = _rows(zero)["1.1"] + assert plain["contract_amount_krw"] == "0" and plain["contract_note"] == "" # 그냥 0 원 줄 + + +def test_동일코드_개별생성() -> None: + shared = _rows({"material_pct": "90"}) + assert shared["1.1"]["contract_code"] == shared["1.2"]["contract_code"] + separate = _rows({"material_pct": "90", "separate_same_code": True}) + assert separate["1.1"]["contract_code"] != separate["1.2"]["contract_code"] + + +def test_묶음_줄_합과_합계_비율() -> None: + result = contract_bill(ROWS, {"material_pct": "90", "labor_pct": "90", "expense_pct": "90"}) + group = result["rows"][0] + children = [r for r in result["rows"] if not r.get("is_group")] + assert Decimal(group["contract_amount_krw"]) == sum( + Decimal(r["contract_amount_krw"]) for r in children + ) + assert result["totals"]["ratio_pct"]["labor"] is not None + + +def test_틀린_적용률은_거른다() -> None: + cleaned, errors = clean_settings({"labor_pct": "-3", "material_pct": "abc", "expense_pct": ""}) + assert len(errors) == 2 and cleaned["expense_pct"] == "100" + + +# ── 2벌 — 「기초단가에 적용」 · 「적용율 적용된 일위대가/산출근거 생성」 ────────────── + + +def _build(): + """자재 1,001 · 노임 2,003 · 산출근거 D-T(노임 0.5) · 일위대가 B-T(자재 2 + D-T 1).""" + book = PriceBook() + for code, kind, price in ( + ("M-A", PriceKind.MATERIAL, "1001"), + ("L-B", PriceKind.LABOR, "2003"), + ): + slots = [None] * 6 + slots[5] = Decimal(price) + book.add_title(PriceTitle(code=code, kind=kind, name=code, slots=slots)) + book.add_title(PriceTitle(code="D-T", kind=PriceKind.PRICE_BASIS, name="산출근거")) + book.add_title(PriceTitle(code="B-T", kind=PriceKind.UNIT_PRICE, name="일위대가")) + book.add_detail(PriceDetail("D-T", "L-B", Decimal("0.5"))) + book.add_detail(PriceDetail("B-T", "M-A", Decimal(2))) + book.add_detail(PriceDetail("B-T", "D-T", Decimal(1))) + return UnitPriceBuild(book=book) + + +def _line(item_no: str, code: str, material: str, labor: str) -> dict: + return { + "item_no": item_no, + "is_group": False, + "in_bill": True, + "quantity": "3", + "price_code": code, + "unit_material_krw": material, + "unit_labor_krw": labor, + "unit_expense_krw": "0", + } + + +BASE_ROWS = [ + _line("2.1", "B-T", "2002", "1001"), # 설계 단가 = 단가표 조립값 + _line("2.2", "B-T", "2500", "1001"), # 수동·할증으로 조립값과 다름 + _line("2.3", "Z-없음", "100", "0"), # 단가표 밖 +] +BASE_ON = { + "material_pct": "90", + "labor_pct": "80", + "apply_to_base_prices": True, + "generate_unit_prices": True, +} + + +def test_기초단가_적용은_복사본만_고치고_다시_조립() -> None: + build = _build() + before = (build.book.resolve("B-T"), copy.deepcopy(build.book.titles["M-A"].slots)) + result = contract_bill(BASE_ROWS, BASE_ON, build=build) + assert (build.book.resolve("B-T"), build.book.titles["M-A"].slots) == before # 설계 단가표 불변 + rows = {row["item_no"]: row for row in result["rows"]} + # 자재 900.9 × 2 = 1801.8 → 1801 · 노임 1602.4 × 0.5 = 801.2 → D 801 → B 801 + # (성분에 곱했으면 노무 1001 × 0.8 = 800.8 → 800 — 층을 다시 조립해야 801) + assert (rows["2.1"]["contract_unit_material_krw"], rows["2.1"]["contract_unit_labor_krw"]) == ( + "1801", + "801", + ) + assert rows["2.1"]["contract_note"].startswith("기초단가 적용") + + +def test_기초단가로_못_푸는_줄은_성분에_곱하고_까닭을_적는다() -> None: + rows = {r["item_no"]: r for r in contract_bill(BASE_ROWS, BASE_ON, build=_build())["rows"]} + assert ( + rows["2.2"]["contract_unit_material_krw"] == "2250" + and "조립값과 다름" in (rows["2.2"]["contract_note"]) + ) + assert "단가표 밖" in rows["2.3"]["contract_note"] + + +def test_계약_호표는_W코드로_따로_서고_산출근거도_따라_선다() -> None: + sheets = contract_bill(BASE_ROWS, BASE_ON, build=_build())["unit_price_sheets"] + by_code = {sheet["code"]: sheet for sheet in sheets} + assert set(by_code) == {"W-B-T", "W-D-T"} + assert (by_code["W-B-T"]["design_code"], by_code["W-B-T"]["total"]) == ("B-T", "2602") + assert by_code["W-D-T"]["labor"] == "801" + + +def test_호표_생성은_기초단가_적용이_함께_켜져야_선다() -> None: + only_sheets = {**BASE_ON, "apply_to_base_prices": False} + result = contract_bill(BASE_ROWS, only_sheets, build=_build()) + assert result["unit_price_sheets"] == [] + assert "generate_unit_prices" in result["options_not_used"] + off = contract_bill(BASE_ROWS, {**BASE_ON, "generate_unit_prices": False}, build=_build()) + assert off["unit_price_sheets"] == [] diff --git a/resources/tester/test_b09_cost_fixtures.py b/resources/tester/test_b09_cost_fixtures.py new file mode 100644 index 00000000..42014b49 --- /dev/null +++ b/resources/tester/test_b09_cost_fixtures.py @@ -0,0 +1,327 @@ +# -*- coding: utf-8 -*- +"""B09 ⑤ 엔진 — 실무 서류 재현 검산 고정값 두 벌 + 거울 테스트 3종. + +목적은 **구조 검증**이지 실무 금액 추종이 아니다 (PLAN 8-10 ★ 사용자 확정: 법대로). + ① **2024 요율** 로 돌리면 그 해 서류가 재현되는가 → 「연도 교체 구조가 실제로 도는가」 + ② **현행 요율** 로 돌리면 규정대로 다른 값이 나오는가 + ③ 안전관리비 A/B min 이 **양방향**으로 도는가 (울진 A 채택 · 거창 B 채택) + +고정값 출처 = PLAN 8-9 · 8-10 · 8-12. + +⚠ 2026-09-14 `tmp/tests/` 에서 **여기로 옮겨 옴**(브레인 지시) — + 시험은 git 안에 둬야 다른 창이 돌림. `tmp/tests/` 쪽 사본은 지우지 않음 + (tmp 는 손대지 않는 자리) — **같은 파일이 두 벌** 있음. +⚠ 짝 벌과 **겹치는 자리** — `test_b09_golden_cost_sheet.py` 가 울진 2024 를 + **XLSX 시트에서** 되풀므로 ① 의 금액 사슬과 겹침. 여기는 **고정값**, + 저기는 **원본 칸**이라 되짚는 길이 달라 둘 다 둠(정리는 사용자 몫). +⚠ 절사는 2026-09-14 부터 **기본이 켜짐**(산림청고시 2025-82호) — 「안 켠 값」을 보는 자리는 + `cut_basis="none"` 으로 손수 끈다. +""" + +import os +from decimal import Decimal + +import pytest + +from B09_Estimation.B09_Estimation_Engine_Cost import ( + CostInput, + calculate_cost, + proposed_profit_adjustment, +) +from B09_Estimation.B09_Estimation_Guards import ( + DoubleCountError, + check_free_haul_not_priced, + check_haul_volume_within_cut, + check_mix_decomposed_once, + check_surcharge_once, +) + +FIXTURE_2024 = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "fixtures", "rates_2024_uljin.json" +) + + +def _won(v) -> Decimal: + return Decimal(str(v)) + + +# 울진 공통(2024) 원가계산서 입력 3종 — PLAN 8-9 +ULJIN_DIRECT_MATERIAL = _won(194_234_417) +ULJIN_DIRECT_LABOR = _won(346_800_554) +ULJIN_DIRECT_EXPENSE = _won(169_324_165) +ULJIN_OWNER_MATERIAL = _won(69_474_220) # 순자재대 (수수료 375,160 별도) +ULJIN_PROCUREMENT_FEE = _won(375_160) + + +def _uljin(**overrides) -> CostInput: + base = dict( + direct_material_krw=ULJIN_DIRECT_MATERIAL, + direct_labor_krw=ULJIN_DIRECT_LABOR, + direct_expense_krw=ULJIN_DIRECT_EXPENSE, + owner_supplied_material_krw=ULJIN_OWNER_MATERIAL, + procurement_fee_krw=ULJIN_PROCUREMENT_FEE, + duration_days=183, + ) + base.update(overrides) + return CostInput(**base) + + +# -------------------------------------------------------------------------- +# ① 2024 요율 재현 — 16/16 금액 사슬 +# -------------------------------------------------------------------------- + + +def _uljin_2024() -> CostInput: + """그 해 서류를 그대로 재현하려면 그 해 관행 처리 둘을 켜야 한다. + + 2024 요율 파일과 그 해 연금 요율만 갈아끼우면 된다 — **관행 옵션은 필요 없다**. + 관급은 **순자재대 69,474,220 + 수수료 375,160** 으로 나눠 넣으면 관급자재대 + `ROUNDUP(69,849,380, −3) = 69,850,000` 과 안전관리비 관급항 + `69,474,220 ÷ 1.1` 이 둘 다 저절로 맞는다. + """ + return _uljin( + rate_file_path=FIXTURE_2024, + pension_year=2024, + profit_adjustment_krw=_won(905_980), + ) + + +def test_uljin_2024_chain_reproduced(): + """2024 요율로 돌리면 그 해 원가계산서가 재현된다 — 금액 사슬 전건.""" + r = calculate_cost(_uljin_2024()) + + assert r.amount("indirect_labor_cost") == _won(50_286_080) + assert r.amount("industrial_accident_insurance") == _won(14_136_284) + assert r.amount("employment_insurance") == _won(4_010_575) + assert r.amount("health_insurance") == _won(12_294_079) + assert r.amount("long_term_care_insurance") == _won(1_592_083) + assert r.amount("national_pension") == _won(15_606_024) + assert r.amount("safety_management_cost") == _won(16_586_996) + assert r.amount("other_expense") == _won(35_479_263) + assert r.amount("environment_preservation") == _won(5_682_873) + assert r.amount("equipment_payment_guarantee") == _won(2_841_436) + + assert r.totals["expense"] == _won(277_553_778) + assert r.totals["net_construction_cost"] == _won(868_874_829) + assert r.amount("general_overhead") == _won(52_132_489) + assert r.amount("profit_before_adjustment") == _won(109_015_935) + assert r.amount("profit") == _won(108_109_955) + assert r.totals["total_cost"] == _won(1_029_117_273) + assert r.amount("vat") == _won(102_911_727) + assert r.totals["contract_amount"] == _won(1_132_029_000) + assert r.amount("owner_supplied_material_total") == _won(69_850_000) + assert r.totals["grand_total"] == _won(1_201_879_000) + + +def test_uljin_2024_safety_adopts_a(): + """2024 울진은 A 채택 — A 16,586,996 < B 18,494,700.""" + r = calculate_cost(_uljin_2024()) + assert r.amount("safety_management_cost_a") == _won(16_586_996) + assert r.amount("safety_management_cost_b") == _won(18_494_700) + assert r.amount("safety_management_cost") == r.amount("safety_management_cost_a") + + +def test_uljin_2024_item_list_comes_from_data(): + """비목 목록은 코드가 아니라 **요율 데이터**가 정한다 (PLAN 8-13). + + 2024 픽스처에는 퇴직공제부금비가 없으므로 그 줄이 서지 않는다. + """ + r = calculate_cost(_uljin_2024()) + assert not r.has("retirement_mutual_aid") + assert not r.has("wage_claim_contribution") + assert r.has("environment_preservation") + + +# -------------------------------------------------------------------------- +# ② 현행 요율 — 같은 입력, 규정대로 다른 값 +# -------------------------------------------------------------------------- + + +def test_owner_supplied_input_is_net_material_not_total(): + """⚠ 관급 입력은 **순자재대**다 — 실무 서류의 「관급자재대」를 그대로 넣으면 틀린다. + + 울진: 순자재대 69,474,220 + 수수료 375,160 = 69,849,380 → 천원 올림 **69,850,000**. + 안전관리비 관급항은 **순자재대만** 부가세 제외로 환산(69,474,220 ÷ 1.1). + 합계를 순자재대 자리에 넣으면 안전관리비가 8,629 원 커진다. + """ + right = calculate_cost(_uljin()) + wrong = calculate_cost(_uljin(owner_supplied_material_krw=_won(69_849_380))) + + assert right.amount("owner_supplied_material_total") == _won(69_850_000) + assert right.amount("safety_management_cost") == _won(18_586_091) + assert wrong.amount("safety_management_cost") - right.amount("safety_management_cost") == _won( + 8_629 + ) + + +def test_uljin_current_rates_safety_18_586_091(): + """현행 요율로 돌리면 안전관리비가 18,586,091 이 된다 (PLAN 8-10). + + A 대상액 604,193,353 × 2.53 % + 3,300,000 = 18,586,091 + B 대상액 541,034,971 × 3.15 % × 1.2 = 20,385,821 → A 채택 + """ + r = calculate_cost(_uljin()) + assert r.amount("safety_management_cost_a") == _won(18_586_091) + assert r.amount("safety_management_cost_b") == _won(20_385_821) + assert r.amount("safety_management_cost") == _won(18_586_091) + + +def test_year_swap_changes_result_but_not_structure(): + """같은 입력·같은 코드, 요율만 갈아끼우면 값이 달라진다 — 교체 구조가 도는 증거.""" + old = calculate_cost(_uljin_2024()) + new = calculate_cost(_uljin()) + + assert old.amount("safety_management_cost") != new.amount("safety_management_cost") + assert old.amount("indirect_labor_cost") != new.amount("indirect_labor_cost") + assert old.rate_version["effective_date"] == "2024-01-01" + assert new.rate_version["effective_date"] == "2026-04-13" + # 픽스처는 지문이 없어 정본이 아님이 드러난다. + assert old.rate_version["sha256"] == "" + assert len(new.rate_version["sha256"]) == 64 + + +def test_current_rates_add_items_absent_in_2024(): + """현행 요율 판에는 2024 에 없던 비목이 붙는다 (PLAN 8-14 사용자 「전부 필요」).""" + r = calculate_cost(_uljin()) + for key in ("retirement_mutual_aid", "wage_claim_contribution", "asbestos_contribution"): + assert r.has(key), key + + +# -------------------------------------------------------------------------- +# ③ 안전관리비 A/B — 양방향 +# -------------------------------------------------------------------------- + + +def test_geochang_adopts_b(): + """거창(2025) 실측 — B 채택. `안전관리비검토` 시트 원문 「적은금액 적용」.""" + r = calculate_cost( + CostInput( + direct_material_krw=_won(80_165_010), + direct_labor_krw=_won(243_648_150), + direct_expense_krw=_won(0), + owner_supplied_material_krw=_won(74_634_214), + enabled_items=("safety_management_cost",), + ) + ) + assert r.amount("safety_management_cost_a") == _won(12_337_367) + assert r.amount("safety_management_cost_b") == _won(12_240_137) + assert r.amount("safety_management_cost") == _won(12_240_137) + + +def test_safety_formula_text_is_per_row(): + """각 줄에 **제 산식**이 붙는다 — 실무 원문은 A 식을 B 줄에 복사한 오류가 있었다.""" + r = calculate_cost(_uljin_2024()) + a = r.line("safety_management_cost_a").formula_text + b = r.line("safety_management_cost_b").formula_text + assert a != b + assert "× 1.2" in b and "× 1.2" not in a + + +# -------------------------------------------------------------------------- +# 이윤 조정 — 보여만 주고 적용은 명시로 +# -------------------------------------------------------------------------- + + +def test_proposed_adjustment_is_advice_only(): + """목표 도급액을 주면 필요한 조정액을 계산해 보여 준다. 스스로 적용하지 않는다.""" + plain = calculate_cost(_uljin(rate_file_path=FIXTURE_2024, pension_year=2024, cut_basis="none")) + suggested = proposed_profit_adjustment(plain, _won(1_132_029_000)) + assert suggested == _won(905_980) + # 제안값을 받았다고 해서 결과가 바뀌지는 않는다. + assert plain.amount("profit") == plain.amount("profit_before_adjustment") + + +# -------------------------------------------------------------------------- +# 거울 테스트 3종 — 이중계상 감시 (PLAN 8-7 ㉠㉡㉢) +# -------------------------------------------------------------------------- + + +def test_guard_surcharge_double_detected(): + """㉠ 자재총괄이 할증 전 값에 두 번 붙으면 잡힌다.""" + check_surcharge_once( + material_summary_total=_won(110), + unit_price_material_total=_won(100), + surcharge_rate_percent=_won(10), + ) + with pytest.raises(DoubleCountError): + check_surcharge_once( + material_summary_total=_won(121), + unit_price_material_total=_won(100), + surcharge_rate_percent=_won(10), + ) + + +def test_guard_free_haul_must_not_be_priced(): + """㉡ 무대 줄은 남기되 단가가 붙으면 잡힌다.""" + rows = [ + {"equipment": "free_haul", "unit_price_krw": 0}, + {"equipment": "dozer", "unit_price_krw": 827}, + ] + check_free_haul_not_priced(haul_rows=rows) + rows[0]["unit_price_krw"] = 500 + with pytest.raises(DoubleCountError): + check_free_haul_not_priced(haul_rows=rows) + + +def test_guard_haul_volume_within_cut(): + """㉡ 보조 — 운반토량 합이 절취량을 넘으면 잡힌다.""" + check_haul_volume_within_cut(haul_volume_total_m3=_won(9_000), total_cut_volume_m3=_won(10_044)) + with pytest.raises(DoubleCountError): + check_haul_volume_within_cut( + haul_volume_total_m3=_won(10_915), total_cut_volume_m3=_won(10_044) + ) + + +def test_guard_mix_decomposed_once(): + """㉢ 시멘트가 콘크리트 체적 × 배합비를 넘으면 잡힌다.""" + check_mix_decomposed_once( + cement_total_kg=_won(3_200), concrete_volume_m3=_won(10), cement_per_m3_kg=_won(320) + ) + with pytest.raises(DoubleCountError): + check_mix_decomposed_once( + cement_total_kg=_won(6_400), concrete_volume_m3=_won(10), cement_per_m3_kg=_won(320) + ) + + +# -------------------------------------------------------------------------- +# 규모 구간 — 추정가격 반복 수렴 (국가계약법 시행령 제7조 1호) +# -------------------------------------------------------------------------- + + +def test_scale_converges_to_estimated_price(): + """구간은 씨앗(직접공사비)이 아니라 **수렴한 추정가격**으로 판정된다. + + 울진은 직접공사비 7.1억 · 총원가 10.3억 — 둘 다 50억 미만이라 구간은 같지만, + 엔진이 씨앗에서 멈추지 않고 총원가로 다시 판정했는지는 아래로 확인한다. + """ + r = calculate_cost(_uljin()) + # 수렴이 끝났으면 「안 굳음」·「진동」 메모가 없다(절사 메모는 늘 붙는다). + assert not [n for n in r.notes if "굳음" in n or "진동" in n], r.notes + + +def test_explicit_estimated_price_skips_convergence(): + """설계자가 추정가격을 명시하면 반복 없이 그 값으로 판정한다.""" + small = calculate_cost(_uljin(estimated_price_krw=_won(1_000_000_000))) + big = calculate_cost(_uljin(estimated_price_krw=_won(6_000_000_000))) + # 50억을 넘기면 일반관리비 8 % → 6.5 %, 이윤 15 % → 12 % 로 갈린다. + assert small.line("general_overhead").rate_percent == Decimal("8.0") + assert big.line("general_overhead").rate_percent == Decimal("6.5") + assert small.line("profit_before_adjustment").rate_percent == Decimal("15.0") + assert big.line("profit_before_adjustment").rate_percent == Decimal("12.0") + + +def test_scale_crossing_bracket_is_resolved_by_convergence(): + """씨앗은 50억 미만인데 총원가가 50억을 넘는 공사 — 구간이 재판정되어야 한다.""" + r = calculate_cost( + CostInput( + direct_material_krw=_won(900_000_000), + direct_labor_krw=_won(2_400_000_000), + direct_expense_krw=_won(1_400_000_000), + duration_days=400, + ) + ) + # 씨앗(직접공사비) 47억 → 8 % 구간. 총원가가 50억을 넘으면 6.5 % 로 내려간다. + assert r.totals["direct_construction_cost"] < _won(5_000_000_000) + assert r.totals["total_cost"] > _won(5_000_000_000) + assert r.line("general_overhead").rate_percent == Decimal("6.5") + assert not [n for n in r.notes if "굳음" in n or "진동" in n], r.notes diff --git a/resources/tester/test_b09_cost_options.py b/resources/tester/test_b09_cost_options.py new file mode 100644 index 00000000..22b69816 --- /dev/null +++ b/resources/tester/test_b09_cost_options.py @@ -0,0 +1,233 @@ +"""원가계산서 기준 입력 10·11·12 — 일반관리비 주/전문 · 절사 · 부가세 방식 (PLAN 6장 새 절). + +⚠ 금액을 박지 않는다 — 구조(어느 밑수·어느 표·끝자리)만 잰다. 실무 값은 식 한 조각만 씀: + 봉화 차액 373 → 이윤 339 · 영월 695 → 632 (÷1.1 반올림) · 영덕(산림조합-면세품) 996 → 996. +⚠ 2026-09-14 — **11 절사 기본이 켜짐**(총공사비 1,000원 미만, 산림청고시 제2025-82호). + 「안 자른 값」을 보는 자리는 `cut_basis="none"` 으로 **손수 꺼서** 본다. +""" + +from __future__ import annotations + +import sys +from dataclasses import replace +from decimal import Decimal +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B09_Estimation.B09_Estimation_Engine_Cost import CostInput, calculate_cost # noqa: E402 +from B09_Estimation.B09_Estimation_Engine_Cost_Options import ( # noqa: E402 + cut_gap, + profit_cut, +) + +BASE = CostInput( + direct_material_krw=Decimal(123_456_789), + direct_labor_krw=Decimal(234_567_891), + direct_expense_krw=Decimal(45_678_912), + estimated_price_krw=Decimal(1_000_000_000), # 10억 — 주공사 8.0 / 전문 6.5 가 갈리는 자리 +) + + +def test_기본값은_주공사_공급가액_총공사비절사() -> None: + """10 주공사 · 12 공급가액 10% · **11 총공사비 1,000원 미만 절사**(2026-09-14 기본으로 켬).""" + explicit = replace( + BASE, + overhead_class="civil_landscape_industrial", + vat_mode="supply", + cut_basis="grand_total", + cut_unit_krw=1000, + ) + assert calculate_cost(explicit).totals == calculate_cost(BASE).totals + assert calculate_cost(BASE).totals["grand_total"] % 1000 == 0 + + +def test_전문공사는_요율표만_바뀐다() -> None: + main, special = ( + calculate_cost(BASE), + calculate_cost(replace(BASE, overhead_class="specialty_electric_communication_fire_other")), + ) + assert ( + main.line("general_overhead").base_amount_krw + == special.line("general_overhead").base_amount_krw + ) + assert ( + main.line("general_overhead").rate_percent != special.line("general_overhead").rate_percent + ) + + +def test_관리품목자재대는_일반관리비_밑수에_든다() -> None: + plain = calculate_cost(BASE) + managed = calculate_cost(replace(BASE, overhead_managed_material_krw=Decimal(10_000_000))) + gain = ( + managed.line("general_overhead").base_amount_krw + - plain.line("general_overhead").base_amount_krw + ) + assert gain == Decimal(10_000_000) + assert "관리품목자재대" in managed.line("general_overhead").base_label + + +@pytest.mark.parametrize( + ("mode", "expected"), + [ + ("material", lambda r, d: r.amount("material_cost")), + ("none", lambda r, d: Decimal(0)), + ("forest_coop", lambda r, d: r.amount("material_cost") + d.direct_expense_krw), + ( + "forest_coop_exempt", + lambda r, d: ( + r.amount("material_cost") - d.tax_exempt_material_krw + d.direct_expense_krw + ), + ), + ], +) +def test_부가세_방식은_밑수만_바꾼다(mode, expected) -> None: + data = replace(BASE, vat_mode=mode, tax_exempt_material_krw=Decimal(3_327_500)) + result = calculate_cost(data) + assert result.line("vat").base_amount_krw == expected(result, data) + if mode == "none": + assert result.amount("vat") == 0 + + +def test_실무_절사_식() -> None: + assert profit_cut(Decimal(373), "grand_total", "supply") == 339 # 봉화 + assert profit_cut(Decimal(695), "grand_total", "supply") == 632 # 영월 + assert profit_cut(Decimal(996), "grand_total", "forest_coop_exempt") == 996 # 영덕 + assert cut_gap(Decimal(1_270_728_373), 1000) == 373 + + +@pytest.mark.parametrize("vat_mode", ["supply", "forest_coop"]) +def test_총공사비_천원_절사는_이윤에서_보정(vat_mode) -> None: + plain = calculate_cost(replace(BASE, vat_mode=vat_mode, cut_basis="none")) + cut = calculate_cost( + replace(BASE, vat_mode=vat_mode, cut_basis="grand_total", cut_unit_krw=1000) + ) + assert cut.amount("grand_total") % 1000 == 0 + assert 0 <= plain.amount("grand_total") - cut.amount("grand_total") < 1000 + assert cut.amount("profit") < plain.amount("profit") + assert cut.has("profit_adjustment") and any("자동보정" in note for note in cut.notes) + # 이윤 말고는 안 움직임 — 순공사원가·일반관리비 그대로. + for key in ("net_construction_cost", "general_overhead"): + assert cut.amount(key) == plain.amount(key) + + +def test_공급가액_절사는_차액_그대로() -> None: + plain = calculate_cost(replace(BASE, cut_basis="none")) + cut = calculate_cost(replace(BASE, cut_basis="supply", cut_unit_krw=10_000)) + assert cut.amount("total_cost") % 10_000 == 0 + assert plain.amount("profit") - cut.amount("profit") == plain.amount("total_cost") % 10_000 + + +def test_절사를_끄면_이윤을_안_건드린다() -> None: + """고르개 「절사 안 함」 = `none`. ⚠ 빈 값도 같이 받는다(저장 안 된 옛 프로젝트).""" + for off in ("none", ""): + assert not calculate_cost(replace(BASE, cut_basis=off)).has("profit_adjustment"), off + # 기준만 있고 단위가 0 이면 안 자른다. + assert not calculate_cost(replace(BASE, cut_unit_krw=0)).has("profit_adjustment") + + +def test_자동보정_줄은_제_이름을_적는다() -> None: + cut = calculate_cost(replace(BASE, cut_basis="grand_total", cut_unit_krw=1000)) + assert "절사 자동보정" in cut.line("profit_adjustment").base_label + manual = calculate_cost(replace(BASE, cut_basis="none", profit_adjustment_krw=Decimal(100))) + assert manual.line("profit_adjustment").base_label == "설계자 명시 입력" + + +def test_고용보험_등급_고르기와_없음() -> None: + auto = calculate_cost(BASE) + grade1 = calculate_cost(replace(BASE, employment_insurance_grade="1")) + none = calculate_cost(replace(BASE, employment_insurance_grade="none")) + assert grade1.line("employment_insurance").rate_percent == Decimal("1.57") + assert grade1.amount("employment_insurance") > auto.amount("employment_insurance") + assert not none.has("employment_insurance") or none.amount("employment_insurance") == 0 + with pytest.raises(Exception): + calculate_cost(replace(BASE, employment_insurance_grade="9")) + + +def test_퇴직공제_적용_여부() -> None: + small = replace(BASE, estimated_price_krw=Decimal(50_000_000)) # 1억 미만 — 자동이면 안 섬 + assert ( + not calculate_cost(small).has("retirement_mutual_aid") + or calculate_cost(small).amount("retirement_mutual_aid") == 0 + ) + applied = calculate_cost(replace(small, retirement_mutual_aid_mode="apply")) + assert applied.amount("retirement_mutual_aid") > 0 + off = calculate_cost(replace(BASE, retirement_mutual_aid_mode="none")) + assert not off.has("retirement_mutual_aid") or off.amount("retirement_mutual_aid") == 0 + + +def test_폐기물_승률_밖_자리() -> None: + """실무 관행(울진소광) — 이윤 뒤 · 총원가 안 · 부가세 안 · 일반관리비·이윤 밑수 밖.""" + waste = Decimal(1_000_000) + law = calculate_cost(replace(BASE, waste_disposal_krw=waste)) + practice = calculate_cost( + replace(BASE, waste_disposal_krw=waste, waste_placement="after_profit") + ) + none = calculate_cost(BASE) + assert practice.amount("general_overhead") == none.amount("general_overhead") + assert practice.amount("profit") == none.amount("profit") + assert practice.amount("total_cost") - none.amount("total_cost") == waste + assert law.amount("total_cost") - practice.amount("total_cost") > 0 # 법 문언 쪽이 승률만큼 큼 + keys = [line.key for line in practice.lines] + assert keys.index("profit") < keys.index("waste_disposal") < keys.index("total_cost") + + +def test_이행보증은_일반계약이면_300억_미만에서_안_선다() -> None: + assert not calculate_cost(BASE).has("performance_guarantee_fee") + tech = calculate_cost(replace(BASE, performance_guarantee_mode="lowest_price_tech")) + assert tech.amount("performance_guarantee_fee") > 0 + + +def test_이행보증_70억_이상은_기초액과_기준액을_쓴다() -> None: + """제비율 기준 §10 — [79만원 + (직공비−75억원) × 0.0070%] × 공기(년).""" + big = CostInput( + direct_material_krw=Decimal(500_000_000), + direct_labor_krw=Decimal(300_000_000), + direct_expense_krw=Decimal(9_200_000_000), # 직공비 100억 · 안전 대상액 8억 + estimated_price_krw=Decimal(12_000_000_000), + duration_days=365, + performance_guarantee_mode="lowest_price_tech", + ) + assert calculate_cost(big).amount("performance_guarantee_fee") == Decimal(790_000 + 175_000) + + +def test_낙찰방식이_하도급대금_요율_줄을_고른다() -> None: + on = replace(BASE, subcontract_guarantee="on") + assert calculate_cost(on).line("subcontract_payment_guarantee").rate_percent == Decimal("0.081") + turnkey = calculate_cost(replace(on, bid_method="turnkey")) + assert turnkey.line("subcontract_payment_guarantee").rate_percent == Decimal("0.084") + comp = calculate_cost(replace(on, bid_method="comprehensive")) + assert comp.line("subcontract_payment_guarantee").rate_percent == Decimal("0.071") + + +def test_사급비_위치_적용기준은_계산에_안_쓴다() -> None: + same = calculate_cost( + replace(BASE, private_material_position="material", contract_law_basis="local") + ) + assert same.totals == calculate_cost(BASE).totals + + +def test_산업안전_50억_이상은_선임_대상으로_가른다() -> None: + """고시 별표1 — 「50억 이상」 열과 「보건관리자 선임 대상」 열은 공사가 선임 대상인가로 갈림.""" + big = replace( + BASE, + direct_material_krw=Decimal(4_000_000_000), + direct_labor_krw=Decimal(3_000_000_000), + estimated_price_krw=Decimal(20_000_000_000), # 토목 선임 기준 1,000억 미만 + ) + assert calculate_cost(big).line("safety_management_cost_b").rate_percent == Decimal("2.6") + target = calculate_cost(replace(big, estimated_price_krw=Decimal(120_000_000_000))) + assert target.line("safety_management_cost_b").rate_percent == Decimal("2.73") + + +def test_하도급대금_지급보증은_기본_꺼짐() -> None: + assert not calculate_cost(BASE).has("subcontract_payment_guarantee") + assert ( + calculate_cost(replace(BASE, subcontract_guarantee="on")).amount( + "subcontract_payment_guarantee" + ) + > 0 + ) diff --git a/resources/tester/test_b09_cost_sheet_forms.py b/resources/tester/test_b09_cost_sheet_forms.py new file mode 100644 index 00000000..2919a7f4 --- /dev/null +++ b/resources/tester/test_b09_cost_sheet_forms.py @@ -0,0 +1,57 @@ +"""원가계산서 나머지 세 형식 — 서식 차례만 · 0 원 안 채움 · 막힌 까닭 · 확인 대기 (2026-09-14 판정). + +근거: STmate 분석 12번 §4~6 · 35번 §1·§2·§5. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B09_Estimation.B09_Estimation_CostSheet_Forms import ( # noqa: E402 + PENDING_PROFIT, + PENDING_SAFETY, + RATE_B, + RATE_RA, + form_rows, +) + + +@pytest.mark.parametrize("form", ["sugong", "actual_general", "actual_sugong"]) +def test_금액은_하나도_안_내고_0원도_없다(form) -> None: + rows = form_rows(form) + assert rows and all(row["amount_krw"] is None for row in rows) + assert all(row["note"] for row in rows) + + +def test_수공은_세부_경비_여덟이_율표_미확보() -> None: + rows = form_rows("sugong") + blocked = [row["label"] for row in rows if row["note"] == RATE_B] + for name in ("수도광열비", "복리후생비", "지급수수료", "폐기물처리비"): + assert name in blocked + assert "기타경비" not in [row["label"] for row in rows] + + +def test_실적형은_이윤이_확인_대기_실적수공은_안전관리비도() -> None: + general = {row["label"]: row["note"] for row in form_rows("actual_general")} + sugong = {row["label"]: row["note"] for row in form_rows("actual_sugong")} + assert general["이 윤"] == PENDING_PROFIT and sugong["이 윤"] == PENDING_PROFIT + assert sugong["안전관리비"] == PENDING_SAFETY + assert general["간접노무비"] == RATE_RA and general["기타경비"] == RATE_RA + + +def test_실적일반_보증_밑수는_직접공사비_환경은_재료직노산출경비() -> None: + """35번 §1 — 12번의 「TA 환경보전비 = 직접공사비」는 06번 정정에 따라 재+직노+산출경비.""" + rows = {row["label"]: row["formula"] for row in form_rows("actual_general")} + assert rows["하도대금 수수료"].startswith("직접공사비") + assert rows["환 경 보 전 비"].startswith("(재료비+직노+산출경비)") + + +def test_모르는_형식은_멈춘다() -> None: + with pytest.raises(ValueError): + form_rows("general") diff --git a/resources/tester/test_b09_cost_sheet_layout.py b/resources/tester/test_b09_cost_sheet_layout.py new file mode 100644 index 00000000..f287f98d --- /dev/null +++ b/resources/tester/test_b09_cost_sheet_layout.py @@ -0,0 +1,140 @@ +"""원가계산서 탭(일반형식) — STmate 서식 차례로 우리 엔진 줄을 늘어놓는가 (PLAN 12장). + +⚠ 금액을 박지 않는다 — 차례·표기·자리(경비 안/총원가 밖)와 「줄 = 엔진 값」만 잰다. +""" + +from __future__ import annotations + +import sys +from decimal import Decimal +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B09_Estimation.B09_Estimation_CostSheet import ( # noqa: E402 + clean_settings, + cost_input, + field_options, + sheet_rows, +) +from B09_Estimation.B09_Estimation_Engine_Cost import calculate_cost # noqa: E402 +from B09_Estimation.B09_Estimation_Rates import load_rate_dataset # noqa: E402 +from B09_Estimation.B09_Estimation_RateTable import amount_label, rate_sections # noqa: E402 + +DIRECT = { + "material": Decimal(30_000_000), + "labor": Decimal(40_000_000), + "expense": Decimal(10_000_000), +} + + +def _sheet(waste: Decimal = Decimal(0), separate: bool = False, stored: dict | None = None): + data = cost_input(DIRECT, stored or {}, waste, separate) + return sheet_rows(calculate_cost(data), data), calculate_cost(data) + + +def test_큰_묶음이_STmate_차례로_선다() -> None: + rows, _ = _sheet() + marks = [ + row["mark"] + for row in rows + if row["mark"] in ("나.", "ㄱ.", "ㄴ.", "ㄷ.", "다.", "라.", "마.", "바.") + ] + assert marks == ["나.", "ㄱ.", "ㄴ.", "ㄷ.", "다.", "라.", "마.", "바."] + + +def test_줄_금액은_엔진_값_그대로() -> None: + rows, result = _sheet() + by_key = {row["key"]: row for row in rows} + for key in ("net_construction_cost", "general_overhead", "profit", "total_cost", "grand_total"): + assert Decimal(by_key[key]["amount_krw"]) == result.amount(key) + assert Decimal(by_key["subtotal"]["amount_krw"]) == result.amount( + "net_construction_cost" + ) + result.amount("general_overhead") + + +def test_경비_세목은_2번부터_차례로_번호() -> None: + rows, _ = _sheet() + expense = [row for row in rows if row["level"] == 2 and row["mark"].endswith(")")] + numbers = [ + int(row["mark"][:-1]) + for row in expense + if row["key"] not in ("direct_labor", "indirect_labor_cost") + ] + assert numbers[0] == 1 and numbers[1:] == list(range(2, len(numbers) + 1)) + + +def test_폐기물은_기본_경비_안_분리발주면_도급공사비_뒤() -> None: + rows, _ = _sheet(Decimal(1_000_000)) + keys = [row["key"] for row in rows] + assert keys.index("waste_disposal") < keys.index("general_overhead") + rows, _ = _sheet(Decimal(1_000_000), separate=True) + keys = [row["key"] for row in rows] + assert keys.index("waste_disposal") > keys.index("contract_amount") + + +def test_저장값은_데이터에_있는_공종만_받는다() -> None: + dataset = load_rate_dataset() + cleaned = clean_settings( + { + "work_type_safety": "civil", + "environment_work_type": "moon_base", + "duration_days": "200", + "owner_supplied_material_krw": "-5", + "profit_adjustment_krw": "", + }, + dataset, + ) + assert cleaned == { + "work_type_safety": "civil", + "duration_days": 200, + "owner_supplied_material_krw": "0", + } + assert {o["value"] for o in field_options(dataset)["work_type_indirect_labor"]} == { + "civil", + "landscape", + "industrial_facilities_civil", + } + + +def test_저장한_기간이_간접노무비_구간을_바꾼다() -> None: + short = {r["key"]: r for r in _sheet(stored={"duration_days": 100})[0]} + long = {r["key"]: r for r in _sheet(stored={"duration_days": 800})[0]} + assert ( + short["indirect_labor_cost"]["rate_percent"] != long["indirect_labor_cost"]["rate_percent"] + ) + + +def test_요율표_구간_표기와_묶음() -> None: + assert amount_label("lt_5_billion") == "50억 미만" + assert amount_label("5_to_30_billion") == "50억~300억 미만" + titles = [section["title"] for section in rate_sections(load_rate_dataset())] + assert titles[0] == "일반관리비 · 이윤" and "산업안전보건관리비" in titles + for section in rate_sections(load_rate_dataset()): + assert all(len(row) == len(section["columns"]) for row in section["rows"]), section["title"] + + +def test_10_11_12_칸이_저장값을_엔진으로_옮긴다() -> None: + dataset = load_rate_dataset() + stored = clean_settings( + { + "overhead_class": "specialty_electric_communication_fire_other", + "cut_basis": "grand_total", + "cut_unit_krw": "1000", + "vat_mode": "forest_coop", + "tax_exempt_material_krw": "5000", + "cut_unit_krw_bad": "7", + }, + dataset, + ) + assert stored["cut_unit_krw"] == 1000 and stored["vat_mode"] == "forest_coop" + data = cost_input(DIRECT, stored) + assert (data.overhead_class, data.cut_basis, data.cut_unit_krw) == ( + "specialty_electric_communication_fire_other", + "grand_total", + 1000, + ) + rows = {r["key"]: r for r in sheet_rows(calculate_cost(data), data)} + assert int(rows["grand_total"]["amount_krw"]) % 1000 == 0 + assert clean_settings({"cut_unit_krw": "7", "vat_mode": "moon"}, dataset) == {} diff --git a/resources/tester/test_b09_dump_haul.py b/resources/tester/test_b09_dump_haul.py new file mode 100644 index 00000000..da497c9d --- /dev/null +++ b/resources/tester/test_b09_dump_haul.py @@ -0,0 +1,126 @@ +"""덤프 운반(산림품셈 10-12 「2. 운반」) — 거리별 호표 · 원문 어긋남은 고른 값·버린 값을 사유에. + +2026-09-14 축 C 1장 (브레인 판정 ①②④⑤): + ① n 의 Qt = 계산값 T/γt×L (원문 표기 10 버림) ② 발파암 E 0.9 ④ Es = 운반 줄 값 + ⑤ 운반거리마다 갈래 · 거리 없으면 0 원 아니라 「운반거리 미입력」 +손셈(토사 164.23 m): Qt 10.2632 · n 16.2907 · t1 6.3885 · t2 3.6131 · ㎝t 12.5016 · f 0.77 + → Q 34.135 → 34.14(사사오입) +""" + +from __future__ import annotations + +import sys +from decimal import Decimal +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B09_Estimation.B09_Estimation_MachineProductivity_Dump import ( # noqa: E402 + DUMP_MATERIALS, + DumpHaul, + dump_child_for, + dump_distance_from_code, + dump_haul_distances, + dump_title_code, +) +from B09_Estimation.B09_Estimation_UnitPrice import cached_build # noqa: E402 + +DISTANCE = Decimal("164.23") + + +def test_토사_운반_식_손셈() -> None: + haul = DumpHaul(DUMP_MATERIALS["FP-10-12-01"], DISTANCE) + assert round(haul.truck_load_m3, 4) == Decimal("10.2632") + assert round(haul.loader_cycles, 4) == Decimal("16.2907") + assert round(haul.cycle_minutes, 4) == Decimal("12.5016") + assert (haul.volume_factor, haul.hourly_output) == (Decimal("0.77"), Decimal("34.14")) + + +def test_암절취는_계산값_Qt_와_운반줄_Es() -> None: + haul = DumpHaul(DUMP_MATERIALS["FP-10-12-02"], DISTANCE) + assert round(haul.truck_load_m3, 4) == Decimal("8.4375") # 원문 표기 10 이 아님 + assert haul.hourly_output == Decimal("12.49") + assert any("원문 표기" in note for note in DUMP_MATERIALS["FP-10-12-02"].notes) + assert DUMP_MATERIALS["FP-10-12-03"].truck_efficiency == Decimal("0.9") + assert any("E 원문 누락" in note for note in DUMP_MATERIALS["FP-10-12-03"].notes) + + +def test_거리마다_호표가_서고_코드에서_거리를_되읽는다() -> None: + build = cached_build(dump_haul_m=(str(DISTANCE),)) + code = dump_title_code("FP-10-12-01", DISTANCE) + assert code == "B-FP-10-12-01#L164.23m" + money = build.book.resolve(code) + assert money.total > 0 + assert dump_distance_from_code(code) == ("164.23",) + assert code not in cached_build().book.titles # 거리 없는 조립엔 안 섬 + + +def test_인계_거리와_갈래_잇기() -> None: + payload = { + "work_items": [ + { + "work_item_code": "FP-10-12", + "haul_equipment": "dump_truck", + "haul_distance_m": 164.23, + }, + {"work_item_code": "FP-10-12", "haul_equipment": "dump_truck", "haul_distance_m": None}, + {"work_item_code": "FP-10-11", "haul_equipment": "dozer", "haul_distance_m": 32.09}, + ] + } + assert dump_haul_distances(payload) == ("164.23",) + assert dump_child_for("리핑암", "2026-01-01") == "FP-10-12-02" # 범위 별칭 리핑암 → 암절취 + assert dump_child_for("토사", "2026-01-01") == "FP-10-12-01" + assert dump_child_for("모르는암", "2026-01-01") is None + + +def test_적재는_거리_무관_한_장이고_E0_는_0_60() -> None: + from B09_Estimation.B09_Estimation_MachineProductivity_Dump import ( + loading_output, + loading_title_code, + ) + + # 토사 3600×0.7×0.9×0.77×0.60/22 = 47.628 → 47.63 + # 암절취 3600×0.7×0.55×0.74×0.35/22 = 16.317 → 16.32 + assert loading_output("FP-10-12-01") == Decimal("47.63") + assert loading_output("FP-10-12-02") == Decimal("16.32") + build = cached_build() # 거리 없어도 섬 + code = loading_title_code("FP-10-12-01") + assert build.book.resolve(code).total > 0 + basis = build.book.details[f"D-{code[2:]}"][0].note + assert "0.75 버림" in basis + + +def test_내역_줄_거리_없으면_운반거리_미입력() -> None: + from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill + + def payload(distance): + return { + "work_items": [ + { + "work_item_code": "FP-10-12", + "name": "dump_truck 운반", + "spec": "토사", + "unit": "㎥", + "quantity": 40.0, + "haul_equipment": "dump_truck", + "haul_distance_m": distance, + "variant_axis": "ground_class", + "variant_value": "토사", + "in_bill": True, + "origin": "haul", + } + ], + "materials": [], + } + + blank = build_bill(payload(None), build=cached_build()) + assert any("운반거리 미입력" in m["reason"] for m in blank.missing) + priced = build_bill(payload(164.23), build=cached_build(dump_haul_m=("164.23",))) + row = next(r for r in priced.rows if r.name == "dump_truck 운반") + assert row.price_code == "B-FP-10-12-01#L164.23m" and row.amount_krw > 0 + loading = payload(None) + loading["work_items"][0].update(name="덤프 적재", haul_equipment="dump_loading") + loaded = build_bill(loading, build=cached_build()) + row = next(r for r in loaded.rows if r.name == "덤프 적재") + assert row.price_code == "B-FP-10-12-01#적재" and row.amount_krw > 0 diff --git a/resources/tester/test_b09_edits.py b/resources/tester/test_b09_edits.py new file mode 100644 index 00000000..ab43f0ca --- /dev/null +++ b/resources/tester/test_b09_edits.py @@ -0,0 +1,211 @@ +"""B09 사용자가 고친 값 — 프로젝트 단위 저장·얹기·되돌리기 (PLAN 12장 2차 · 2026-09-14 브레인 판정). + +겨누는 것 + ① 고친 값은 **복사본에** 얹힘 — 기본 벌(캐시)은 그대로(골든셋·다른 프로젝트가 안 흔들림) + ② 자재단가대비표 채택 슬롯 — 값 있는 슬롯만 받음 · 채택하면 금액이 그 슬롯 값으로 섬 + ③ 지우면(↺) 계산값으로 돌아감 · 고친 값이 없으면 캐시 키가 빈 글 +""" + +from __future__ import annotations + +from decimal import Decimal +from typing import Any + +import pytest + +from B09_Estimation.B09_Estimation_Edits import ( + EditError, + apply_edits, + edits_key, + merge_changes, +) +from B09_Estimation.B09_Estimation_PriceBook import PriceBook, PriceDetail, PriceKind, PriceTitle +from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild + + +def _build() -> UnitPriceBuild: + book = PriceBook() + slots = [Decimal(100), None, Decimal(90), None, None, Decimal(120)] + book.add_title(PriceTitle("M-1", PriceKind.MATERIAL, "시멘트", slots=slots)) + book.add_title(PriceTitle("B-1", PriceKind.UNIT_PRICE, "타설")) + book.add_detail(PriceDetail("B-1", "M-1", Decimal(2))) + return UnitPriceBuild(book=book) + + +def test_채택_슬롯을_바꾸면_복사본에서만_금액이_바뀐다() -> None: + base = _build() + edits = merge_changes({}, base, [{"section": "adopted_slots", "key": "M-1", "value": "3"}]) + assert edits == {"adopted_slots": {"M-1": 3}} + edited = apply_edits(base, edits) + assert edited.book.resolve("B-1").material == Decimal(180) # 90 × 2 + assert base.book.resolve("B-1").material == Decimal(240) # 기본 벌은 6번 120 그대로 + assert edited.edit_skipped == [] + + +def test_값_없는_슬롯은_받지_않는다() -> None: + with pytest.raises(EditError): + merge_changes({}, _build(), [{"section": "adopted_slots", "key": "M-1", "value": 2}]) + with pytest.raises(EditError): + merge_changes({}, _build(), [{"section": "adopted_slots", "key": "B-1", "value": 1}]) + + +def test_지우면_계산값으로_돌아가고_캐시_키가_빈다() -> None: + base = _build() + edits = merge_changes( + {"adopted_slots": {"M-1": 1}}, + base, + [{"section": "adopted_slots", "key": "M-1", "value": None}], + ) + assert edits == {} and edits_key(edits) == "" + assert edits_key({"adopted_slots": {"M-1": 1}}) != "" + + +def _book_with_basis() -> UnitPriceBuild: + build = _build() + book = build.book + book.add_title( + PriceTitle("L-1", PriceKind.LABOR, "보통인부", slots=[None] * 5 + [Decimal(100000)]) + ) + book.add_title( + PriceTitle("X-1", PriceKind.MACHINE_BASE, "굴착기", slots=[None] * 5 + [Decimal(55700)]) + ) + book.add_detail(PriceDetail("B-1", "L-1", Decimal("0.1"))) + # D — Q 15.71 로 선 장비 줄(몫 1): 수량 1/15.71 + book.add_output_detail("B-1", "X-1", Decimal(1) / Decimal("15.71"), "Q 식", Decimal("15.71")) + return build + + +def test_구성행_수량을_고치고_빼고_더하면_금액이_다시_서고_지우면_돌아온다() -> None: + base = _book_with_basis() + before = base.book.resolve("B-1") + changes = [ + { + "section": "sheet_rows", + "key": "B-1|1", + "value": {"quantity": "0.2"}, + }, # 보통인부 0.1 → 0.2 + {"section": "sheet_rows", "key": "B-1|0", "value": {"removed": True}}, # 시멘트 뺌 + {"section": "sheet_rows", "key": "B-1|+1", "value": {"ref": "L-1", "quantity": "0.05"}}, + ] + edits = merge_changes({}, base, changes) + edited = apply_edits(base, edits) + after = edited.book.resolve("B-1") + assert after.material == 0 and after.labor == Decimal(25000) # 0.2×10만 + 0.05×10만 + marks = edited.edit_rows["B-1"] + assert marks[0]["removed"] and marks[1]["was_quantity"] == "0.1" and marks[3]["added"] + assert base.book.resolve("B-1") == before # 기본 벌 그대로 + cleared = merge_changes(edits, base, [{**c, "value": None} for c in changes]) + assert cleared == {} and apply_edits(base, cleared).book.resolve("B-1") == before + + +def test_Q_식을_고치면_수량이_옛_Q_를_새_Q_로_바꾼_만큼_변한다() -> None: + base = _book_with_basis() + key = "D-1|0" + edits = merge_changes( + {}, + base, + [ + { + "section": "price_basis_q", + "key": key, + "value": {"q_formula": "3600*0.2*0.7*0.85*0.55/15*2"}, + } + ], + ) + edited = apply_edits(base, edits) + detail = edited.book.details["D-1"][0] + assert detail.output == Decimal("31.42") + assert detail.quantity == Decimal(1) / Decimal("15.71") * Decimal("15.71") / Decimal("31.42") + assert edited.edit_rows["D-1"][0]["q_formula"].startswith("3600") + # 줄 0.1원 · 머리 원 미만 절사: 55,700 ÷ 31.42 = 1,772.7 → 1,772 + assert edited.book.resolve("D-1").expense == Decimal(1772) + + +def test_Q_로_안_선_줄·비율_줄·돌림_참조는_받지_않는다() -> None: + base = _book_with_basis() + with pytest.raises(EditError): + merge_changes( + {}, base, [{"section": "price_basis_q", "key": "B-1|1", "value": {"q_formula": "10"}}] + ) + with pytest.raises(EditError): + merge_changes( + {}, + base, + [{"section": "sheet_rows", "key": "B-1|+1", "value": {"ref": "B-1", "quantity": "1"}}], + ) + with pytest.raises(EditError): + merge_changes( + {}, + base, + [{"section": "sheet_rows", "key": "D-1|+1", "value": {"ref": "B-1", "quantity": "1"}}], + ) + + +def test_단가표에서_사라진_코드는_버리지_않고_남긴다() -> None: + edited = apply_edits(_build(), {"adopted_slots": {"M-없음": 1, "M-1": 2}}) + assert len(edited.edit_skipped) == 2 + assert edited.book.titles["M-1"].adopted_slot == 6 + + +def _bill_row(code: str, spec: str = "", quantity: str = "10") -> Any: + from B09_Estimation.B09_Estimation_BillOfQuantities import BillRow + + row = BillRow(item_no="1", level=1, code=code, name=code, spec=spec, quantity=Decimal(quantity)) + row.price_code = f"B-{code}" + row.unit_material_krw, row.unit_labor_krw, row.unit_expense_krw = ( + Decimal(1000), + Decimal(2000), + Decimal(333), + ) + row.unit_price_krw = Decimal(3333) + row.amount_krw = Decimal(33330) + return row + + +def test_할증율은_줄_칸이_이기고_없으면_내역서_전체가_얹힌다() -> None: + from B09_Estimation.B09_Estimation_BillOfQuantities import bill_line + from B09_Estimation.B09_Estimation_BillRates import apply_bill_rates + + first, second = _bill_row("A"), _bill_row("B") + rates = { + "B-A|": {"all": "10"}, + "*": { + "material": "0", + "labor": "5", + "expense": "3", + "rounding": {"expense": {"mode": "round", "digits": 1}}, + }, + } + apply_bill_rates([first, second], rates, bill_line) + # 줄 칸 10% — 성분마다 원 미만 절사: 1,100 · 2,200 · 366.3 → 366 + assert (first.unit_material_krw, first.unit_labor_krw, first.unit_expense_krw) == ( + Decimal(1100), + Decimal(2200), + Decimal(366), + ) + assert first.amount_krw == Decimal(36660) and first.rate["source"] == "B-A|" + # 전체 칸 — 경비는 반올림 소수 1자리: 333 × 1.03 = 342.99 → 343.0 · 노무 2,100 + assert second.unit_expense_krw == Decimal("343.0") and second.unit_labor_krw == Decimal(2100) + assert second.amount_krw == Decimal(10000 + 21000 + 3430) + assert "내역서 전체" in second.note + + +def test_할증율_모양이_틀리면_받지_않고_지우면_돌아온다() -> None: + base = _build() + with pytest.raises(EditError): + merge_changes({}, base, [{"section": "bill_rates", "key": "*", "value": {"all": "-100"}}]) + with pytest.raises(EditError): + merge_changes( + {}, + base, + [ + { + "section": "bill_rates", + "key": "*", + "value": {"all": "5", "rounding": {"mode": "ceil", "digits": 0}}, + } + ], + ) + edits = merge_changes({}, base, [{"section": "bill_rates", "key": "*", "value": {"all": "5"}}]) + assert edits["bill_rates"]["*"]["all"] == "5" and edits_key(edits) == "" # 조립 캐시엔 안 듦 + assert merge_changes(edits, base, [{"section": "bill_rates", "key": "*", "value": None}]) == {} diff --git a/resources/tester/test_b09_execution.py b/resources/tester/test_b09_execution.py new file mode 100644 index 00000000..8f6ae88c --- /dev/null +++ b/resources/tester/test_b09_execution.py @@ -0,0 +1,168 @@ +"""실행예산 단계 — 설계 → 실행예산 **구조**가 서는가 (PLAN 12장 · 2026-09-14 브레인 배정). + +⚠ 값이 맞는지는 못 잰다 — 실행예산 표본 0건(STmate 27번 §9 · 35번). 여기서 재는 것: + ① 시간당 실행단가 = 1단위당 사용료 ÷ 시간 → 설계 성분 비율 → 성분마다 절사 → 차액 보정 + ② 「기본보정」은 뜻 미확인 — 차액을 안 몰고 남김 + ③ 중기(X)를 갈아 끼운 **복사본**으로 일위대가 다시 조립 · 설계 단가표·입력 줄 불변 + ④ 실행수량 · 조립값과 다른 줄은 설계 단가 + 까닭 · 묶음 줄 합 · 틀린 칸 거름 +""" + +from __future__ import annotations + +import copy +import sys +from decimal import Decimal +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B09_Estimation.B09_Estimation_Execution import ( # noqa: E402 + clean_settings, + execution_bill, + execution_hourly, +) +from B09_Estimation.B09_Estimation_PriceBook import ( # noqa: E402 + Money3, + PriceBook, + PriceDetail, + PriceKind, + PriceTitle, +) +from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild # noqa: E402 + +DESIGN_X = Money3(material=Decimal(2000), labor=Decimal(3000), expense=Decimal(1500)) + + +def _parts(money: Money3) -> tuple[str, str, str]: + return (str(money.material), str(money.labor), str(money.expense)) + + +def test_시간당_실행단가를_설계_성분_비율로_가르고_차액을_고른_비목에() -> None: + # 80,000원/일 ÷ 8시간 = 10,000 · 비율 2000:3000:1500 → 3076.9 / 4615.3 / 2307.6 → 절사 합 9,998 + money, diff, note = execution_hourly(DESIGN_X, Decimal(80000), Decimal(8), Decimal(1), "labor") + assert _parts(money) == ("3076", "4617", "2307") and diff == 2 and "노무비" in note + money, _, _ = execution_hourly(DESIGN_X, Decimal(80000), Decimal(8), Decimal(100), "expense") + assert _parts(money) == ("3000", "4600", "2400") # 100원 미만 절사 · 차액 100 → 경비 + + +def test_기본보정은_뜻_미확인이라_차액을_남긴다() -> None: + money, diff, note = execution_hourly(DESIGN_X, Decimal(80000), Decimal(8), Decimal(1), "basic") + assert money.total == 9998 and diff == 2 and "기본보정" in note + + +def _build() -> UnitPriceBuild: + """중기 X-G(연료 2 · 운전원 1 · 취득가 0.0003) · B-T(X-G 0.5 + 노임 1) · B-N(연료만).""" + book = PriceBook() + for code, kind, price in ( + ("M-F", PriceKind.MATERIAL, "1000"), + ("L-O", PriceKind.LABOR, "3000"), + ("L-B", PriceKind.LABOR, "2000"), + ("S-G", PriceKind.MACHINE_BASE, "5000000"), + ): + slots = [None] * 6 + slots[5] = Decimal(price) + book.add_title(PriceTitle(code=code, kind=kind, name=code, slots=slots)) + book.add_title(PriceTitle(code="X-G", kind=PriceKind.MACHINE_HOURLY, name="굴삭기")) + book.add_title(PriceTitle(code="B-T", kind=PriceKind.UNIT_PRICE, name="흙깎기")) + book.add_title(PriceTitle(code="B-N", kind=PriceKind.UNIT_PRICE, name="자재만")) + for parent, ref, qty in ( + ("X-G", "M-F", "2"), + ("X-G", "L-O", "1"), + ("X-G", "S-G", "0.0003"), + ("B-T", "X-G", "0.5"), + ("B-T", "L-B", "1"), + ("B-N", "M-F", "1"), + ): + book.add_detail(PriceDetail(parent, ref, Decimal(qty))) + return UnitPriceBuild(book=book) + + +def _line(item_no: str, code: str, unit: tuple[str, str, str], qty: str = "3") -> dict: + return { + "item_no": item_no, + "is_group": False, + "in_bill": True, + "quantity": qty, + "price_code": code, + "unit_material_krw": unit[0], + "unit_labor_krw": unit[1], + "unit_expense_krw": unit[2], + } + + +ROWS = [ + {"item_no": "2", "is_group": True, "in_bill": True}, + _line("2.1", "B-T", ("1000", "3500", "750")), # 설계 = 조립값 + _line("2.2", "B-N", ("1000", "0", "0")), # 중기 없음 + _line("2.3", "B-T", ("1200", "3500", "750")), # 수동 단가 — 조립값과 다름 +] +SETTINGS = { + "machines": { + "X-G": { + "unit_price_krw": "80000", + "hours_per_unit": "8", + "cut_unit_krw": "1", + "correction": "labor", + } + }, + "quantities": {"2.2": "5"}, +} + + +def test_중기를_갈아_끼운_복사본으로_다시_조립하고_설계는_그대로() -> None: + build = _build() + before = (build.book.resolve("X-G"), build.book.resolve("B-T"), len(build.book.titles)) + rows_before = copy.deepcopy(ROWS) + result = execution_bill(ROWS, clean_settings(SETTINGS)[0], build=build) + assert (build.book.resolve("X-G"), build.book.resolve("B-T"), len(build.book.titles)) == before + assert ROWS == rows_before + rows = {row["item_no"]: row for row in result["rows"]} + # X-G 실행 3076/4617/2307 × 0.5 = 1538 / 2308.5 / 1153.5 + 노임 2000 → 원 미만 절사 + assert ( + rows["2.1"]["execution_unit_material_krw"], + rows["2.1"]["execution_unit_labor_krw"], + rows["2.1"]["execution_unit_expense_krw"], + ) == ("1538", "4308", "1153") + assert "X-G" in rows["2.1"]["execution_note"] + machine = result["machines"][0] + assert machine["code"] == "X-G" and machine["execution"]["total_krw"] == "10000" + assert machine["design"]["total_krw"] == "6500" + + +def test_실행수량과_중기_없는_줄() -> None: + rows = {r["item_no"]: r for r in execution_bill(ROWS, SETTINGS, build=_build())["rows"]} + assert ( + rows["2.2"]["execution_quantity"] == "5" and rows["2.2"]["execution_amount_krw"] == "5000" + ) + assert rows["2.2"]["execution_note"] == "" and rows["2.1"]["execution_quantity"] == "3" + + +def test_조립값과_다른_줄은_설계_단가와_까닭() -> None: + row = execution_bill(ROWS, SETTINGS, build=_build())["rows"][3] + assert row["execution_unit_material_krw"] == "1200" and "못 얹음" in row["execution_note"] + + +def test_설정이_없으면_설계와_같고_묶음_줄_합() -> None: + result = execution_bill(ROWS, {}, build=_build()) + assert result["totals"]["design"] == result["totals"]["execution"] + group = result["rows"][0] + children = [r for r in result["rows"] if not r.get("is_group")] + assert Decimal(group["execution_amount_krw"]) == sum( + Decimal(r["execution_amount_krw"]) for r in children + ) + + +def test_틀린_칸은_거른다() -> None: + cleaned, errors = clean_settings( + { + "machines": { + "X-A": {"unit_price_krw": ""}, + "X-B": {"unit_price_krw": "100", "hours_per_unit": "0"}, + "X-C": {"unit_price_krw": "100", "hours_per_unit": "8", "cut_unit_krw": "7"}, + }, + "quantities": {"1.1": "-1", "1.2": ""}, + } + ) + assert len(errors) == 2 and list(cleaned["machines"]) == ["X-C"] + assert cleaned["machines"]["X-C"]["cut_unit_krw"] == "1" and cleaned["quantities"] == {} diff --git a/resources/tester/test_b09_expression.py b/resources/tester/test_b09_expression.py new file mode 100644 index 00000000..d24e632b --- /dev/null +++ b/resources/tester/test_b09_expression.py @@ -0,0 +1,35 @@ +"""B09 Q 식 편집이 쓰는 식 — **명세 13장 식 언어**(B08 구조물도 풀이기 한 벌)로 풀고 소수 2자리 사사오입. + +⚠ 2026-09-14 브레인 판정 — 식 풀이를 B09 에 두 벌 짜지 않음. 종전 `B09_Estimation_Expression`(파이썬 셈)은 걷어냄. +""" + +from __future__ import annotations + +from decimal import Decimal + +from B09_Estimation.B09_Estimation_Edits import evaluate_q + + +def test_실무_Q_식을_명세_13장_언어로_풀고_2자리로_확정한다() -> None: + got = evaluate_q( + { + # 18번 §2.2 — f 는 0.85 로 먼저 확정해 넣음 → 15.708 → 15.71 + "a": "3600*0.2*0.7*0.85*0.55/15", + # 울진 대흥 제15호표 되메우기 Q 68.04 + "b": "3600*0.7*0.9*(1/1.25)*0.75/20", + # 콘크리트믹서 Q = 60 / t × q × E = 5.40 + "c": "60/4*0.45*0.8", + "d": "MAX(12.345, SQRT(100))", + } + ) + assert got == { + "a": Decimal("15.71"), + "b": Decimal("68.04"), + "c": Decimal("5.4"), + "d": Decimal("12.35"), + } + + +def test_모르는_이름·0_이하는_까닭으로_돌려준다() -> None: + got = evaluate_q({"x": "abc+1", "y": "0*5", "z": "1/0"}) + assert all(isinstance(value, str) and value for value in got.values()), got diff --git a/resources/tester/test_b09_golden_cost_sheet.py b/resources/tester/test_b09_golden_cost_sheet.py new file mode 100644 index 00000000..853f3076 --- /dev/null +++ b/resources/tester/test_b09_golden_cost_sheet.py @@ -0,0 +1,397 @@ +"""STmate 골든셋 ⑤ — 공사원가계산서를 **우리 엔진**으로 되풀어 대조(PLAN 6장·12장 · 명세 8장). + +짝 파일 `test_b09_golden_stmate.py` 의 다섯째 벌임 — 그 파일이 700줄에 닿아 갈라 둠. +실무 원본 읽기는 그 파일의 `_workbooks` 를 그대로 씀(한 번만 읽어 나눠 쓰는 자리). + +재는 것 — 제비율(법정경비) · 간접노무비 · 일반관리비 · 이윤 · 부가세 · 도급공사비 · 총공사비. + 요율은 **원본 비고에서 뽑아** 그 원본 전용 요율 판을 지어 넣음 — + 「요율이 달라서 틀림」이 안 생김. + 남는 차이는 **밑수 고르기 · 원 단위 버림 · 사슬**, 곧 우리 엔진 몫임. + +⚠ 실무 원본은 **git 안 지식DB**라 어느 창에서든 돎. 원본이 없으면 건너뜀(시험 코드 탓이 아님). +⚠ 값을 여기서 짓지 않음 — 원본 칸을 읽어 **엔진 입력**으로만 씀. +⚠ 이 벌은 **재기만 함** — 값을 맞추려고 엔진을 고치지 않음(브레인 지시 2026-09-14). +""" + +from __future__ import annotations + +import json +import pathlib +import re +from dataclasses import replace +from decimal import Decimal + +import pytest +from test_b09_golden_stmate import _num, _workbooks + +from B09_Estimation.B09_Estimation_Engine_Cost import CostInput, calculate_cost + +#: 원본 줄 이름(공백 지운 것) → 엔진 줄 키. 「소계」 셋은 나온 차례로 재료비·노무비·경비. +_COST_KEYS: dict[str, str] = { + "직접재료비": "direct_material", + "간접재료비": "indirect_material", + "작업설,부산물등(△)": "byproduct", + "작업설.부산물등(△)": "byproduct", + "직접노무비": "direct_labor", + "간접노무비": "indirect_labor_cost", + "산출경비": "direct_expense", + "산재보험료": "industrial_accident_insurance", + "고용보험료": "employment_insurance", + "건강보험료": "health_insurance", + "노인장기요양보험료": "long_term_care_insurance", + "연금보험료": "national_pension", + "산업안전보건관리비": "safety_management_cost", + "기타경비": "other_expense", + "환경보전비": "environment_preservation", + "건설기계대여금지급보증금액": "equipment_payment_guarantee", + "순공사원가": "net_construction_cost", + "일반관리비": "general_overhead", + "이윤": "profit", + "페기물처리": "waste_disposal", # 원본 표기(소광) 그대로 + "폐기물처리비": "waste_disposal", + "총원가": "total_cost", + "부가가치세": "vat", + "도급공사비": "contract_amount", + "관급자재대": "owner_supplied_material_total", + "관급자재대(도급자설치)": "owner_supplied_material_total", + "총공사비": "grand_total", +} +#: 원본 비고에 요율이 적히는 법정경비 줄 — 이 줄만 엔진과 견줌. +_STATUTORY_KEYS = ( + "industrial_accident_insurance", + "employment_insurance", + "health_insurance", + "long_term_care_insurance", + "national_pension", + "safety_management_cost", + "other_expense", + "environment_preservation", + "equipment_payment_guarantee", +) +_RE_PERCENT = re.compile(r"[x×]\s*([\d.]+)\s*%") +_RE_FLAT_ADD = re.compile(r"%\s*\+\s*([\d,]+)") +_RE_OWNER_NOTE = re.compile(r"원자재대[::]\s*([\d,]+).*?조달수수료[::]\s*([\d,]+)", re.S) +_RE_EXEMPT = re.compile(r"면세품목[::]\D*([\d,]+)") +_RE_SAFETY_AB = re.compile(r"A[))][^=]*=\s*([\d,]+).*?B[))][^=]*=\s*([\d,]+)", re.S) +#: 합성 요율 판의 「어디에나 걸리는」 구간·기간 라벨 — 구간 갈래는 이 벌이 재는 것이 아님. +_WIDE_BRACKET = "lt_1000_billion" +_WIDE_DURATION = "lte_100000_days" +#: 연금 요율을 원본이 보인 값 그대로 쓰려고 두는 표식 해(연도 갈래는 이 벌 밖). +_SHEET_YEAR = 2000 + + +def _won(text: object) -> Decimal: + return Decimal(str(text).replace(",", "")) + + +def _cost_rows(rows: list[tuple]) -> tuple[dict[str, tuple], list[tuple[str, Decimal]]]: + """`공사원가계산서` 시트 → `({키: (금액, 요율, 기초액, 비고)}, 이름 못 고른 줄들)`. + + 금액 칸은 「금 액」 머리로 찾고, 당초/변경/증감으로 갈린 원본(영덕 변경설계)은 **변경** 칸을 씀. + """ + head = next(r for r in rows if any("금" in str(c or "") and "액" in str(c or "") for c in r)) + amount_col = next( + i for i, c in enumerate(head) if "금" in str(c or "") and "액" in str(c or "") + ) + note_col = next( + (i for i, c in enumerate(head) if "비" in str(c or "") and "고" in str(c or "")), None + ) + body = rows[rows.index(head) + 1 :] + for index, cell in enumerate(body[0] if body else ()): + if "변" in str(cell or "") and "경" in str(cell or ""): + amount_col = index + got: dict[str, tuple] = {} + unknown: list[tuple[str, Decimal]] = [] + subtotals = 0 + for row in body: + label = "" + for cell in row[:amount_col]: + if isinstance(cell, str) and cell.strip(): + label = cell.replace(" ", "").replace(" ", "") + amount = _num(row[amount_col]) if amount_col < len(row) else None + if amount is None or not label: + continue + if label == "소계": + subtotals += 1 + key = ("material_cost", "labor_cost", "expense")[subtotals - 1] + else: + key = _COST_KEYS.get(label) + if key is None: + unknown.append((label, amount)) + continue + note = ( + " ".join(str(c) for c in row[note_col:] if isinstance(c, str)) + if note_col is not None + else "" + ) + percent = _RE_PERCENT.search(note) + flat = _RE_FLAT_ADD.search(note) + got[key] = ( + amount, + Decimal(percent.group(1)) if percent else None, + _won(flat.group(1)) if flat else Decimal(0), + note, + ) + return got, unknown + + +def _cost_sheets() -> list[tuple[str, dict[str, tuple], list[tuple[str, Decimal]]]]: + return [ + (name, *_cost_rows(sheets["공사원가계산서"])) + for name, sheets in _workbooks() + if "공사원가계산서" in sheets + ] + + +def _sheet_rate_dataset(got: dict[str, tuple]) -> dict: + """원본 비고에 적힌 요율만으로 **그 원본 전용 요율 판**을 지음. + + 비고가 빈 줄은 판에 안 들어가고, 그 줄은 엔진이 아예 안 세움(`available_items`). + """ + + def percent(key: str) -> Decimal | None: + row = got.get(key) + return row[1] if row else None + + variables: dict[str, object] = {} + if (rate := percent("indirect_labor_cost")) is not None: + variables["rate_indirect_labor"] = { + "brackets": [ + { + "direct_cost_bracket": _WIDE_BRACKET, + "duration_bracket": _WIDE_DURATION, + "work_type": "civil", + "rate_percent": float(rate), + } + ] + } + if (rate := percent("industrial_accident_insurance")) is not None: + variables["rate_sanjae"] = {"rate_percent": float(rate)} + if (rate := percent("employment_insurance")) is not None: + variables["rate_goyong"] = { + "brackets": [ + { + "grade": "below_7", + "estimated_amount_bracket": "below_official_threshold", + "rate_percent": float(rate), + } + ] + } + if (rate := percent("health_insurance")) is not None: + variables["rate_health"] = {"rate_percent": float(rate)} + if (rate := percent("long_term_care_insurance")) is not None: + variables["rate_care"] = {"rate_percent": float(rate)} + if (rate := percent("national_pension")) is not None: + variables["rate_pension"] = { + "annual_rates": [{"year": _SHEET_YEAR, "rate_percent": float(rate)}] + } + if (rate := percent("safety_management_cost")) is not None: + variables["rate_safety_pct"] = { + "brackets": [ + { + "target_amount_bracket": _WIDE_BRACKET, + "work_type": "civil", + "rate_percent": float(rate), + "base_amount_krw": float(got["safety_management_cost"][2]), + } + ] + } + if (rate := percent("other_expense")) is not None: + variables["rate_other_expense"] = { + "brackets": [ + { + "direct_cost_bracket": _WIDE_BRACKET, + "duration_bracket": _WIDE_DURATION, + "work_type": "civil", + "rate_percent": float(rate), + } + ] + } + if (rate := percent("environment_preservation")) is not None: + variables["rate_environment"] = { + "all_work_types": [{"work_type": "civil_road", "rate_percent": float(rate)}] + } + if (rate := percent("equipment_payment_guarantee")) is not None: + variables["rate_equipment_payment_guarantee"] = { + "general_construction": [{"work_type": "civil_general", "rate_percent": float(rate)}], + "specialty_construction": [], + } + variables["rate_overhead"] = { + "civil_landscape_industrial": [ + { + "estimated_price_bracket": _WIDE_BRACKET, + "rate_percent": float(percent("general_overhead")), + } + ] + } + variables["rate_profit"] = { + "brackets": [ + {"estimated_price_bracket": _WIDE_BRACKET, "rate_percent": float(percent("profit"))} + ] + } + variables["rate_vat"] = {"rate_percent": float(percent("vat") or 0)} + return {"dataset_id": "sheet", "effective_date": "sheet", "variables": variables} + + +def _cost_input(got: dict[str, tuple], rate_path: pathlib.Path) -> CostInput: + """원본 칸을 엔진 입력으로 옮김 — 직접비 셋 · 관급 순자재대·수수료 · 부가세 방식 · 폐기물.""" + rate_path.write_text(json.dumps(_sheet_rate_dataset(got)), encoding="utf-8") + owner = _RE_OWNER_NOTE.search(got.get("owner_supplied_material_total", (0, 0, 0, ""))[3]) + gross, fee = (_won(owner.group(1)), _won(owner.group(2))) if owner else (Decimal(0), Decimal(0)) + exempt = _RE_EXEMPT.search(got.get("vat", (0, 0, 0, ""))[3]) + return CostInput( + direct_material_krw=got["direct_material"][0], + direct_labor_krw=got["direct_labor"][0], + direct_expense_krw=got["direct_expense"][0], + indirect_material_krw=got.get("indirect_material", (Decimal(0),))[0], + # ⚠ 관급 입력은 **순자재대** — 원본 비고의 원자재대는 조달수수료를 이미 머금음. + owner_supplied_material_krw=gross - fee, + procurement_fee_krw=fee, + pension_year=_SHEET_YEAR, + vat_mode="forest_coop_exempt" if exempt else "supply", + tax_exempt_material_krw=_won(exempt.group(1)) if exempt else Decimal(0), + waste_disposal_krw=got.get("waste_disposal", (Decimal(0),))[0], + waste_placement="after_profit", # 소광 원본 자리 — 이윤 뒤·총원가 안 + rate_file_path=str(rate_path), + ) + + +def test_원가계산서_법정경비_줄마다_실무_원본_재현(tmp_path) -> None: + """⑤ 제비율 줄 — 밑수 고르기 + 원 단위 버림이 원본과 **줄마다** 같음(실무 6건). + + 안 맞을 자리를 셋으로 갈라 둠 — + · 우리 엔진이 틀림 → `misses` 에 남아 실패로 드러남 + · 요율 데이터가 다름 → 요율을 원본에서 뽑아 쓰므로 **안 생김** + · 원본이 식을 안 보임 → 비고 빈 줄(건강·노인장기·연금·산업안전). 앞 차수 값을 그대로 + 물려 적은 자리라 되풀 수 없음 — 울진 신설·영덕·소광 각 4줄, 세어만 두고 대조 밖. + · 절사 자리가 다름 → 안전관리비 **B 줄** 1원(영월). 아래 `rounding` 에 따로 담음. + + ⭐ **고칠 자리 하나 — 안전관리비 B 의 절사 자리**(2026-09-14 실측, 브레인 보고 대상). + 우리 엔진 `버림((밑수 × 율 + 기초액) × 1.2)` 영월 20,330,639 + STmate `버림(밑수 × 율 + 기초액) × 1.2` 뒤 버림 영월 20,330,638 + 여섯 원본 중 영월에서만 갈림(봉화·울진은 두 셈법이 같은 값). + **미채택 줄이라 총액엔 안 닿음** — + 영월도 A 를 채택함. 이 벌은 재기만 하므로 엔진은 그대로 두고 차를 세어만 둠. + """ + sheets = _cost_sheets() + if not sheets: + pytest.skip("실무 원본 XLSX 가 없음") + checked, blank, misses, rounding = 0, 0, [], [] + for index, (name, got, _unknown) in enumerate(sheets): + blank += sum(1 for k in _STATUTORY_KEYS if k in got and got[k][1] is None) + result = calculate_cost(_cost_input(got, tmp_path / f"rate{index}.json")) + for key in ("indirect_labor_cost", *_STATUTORY_KEYS): + if key not in got or got[key][1] is None: + continue + checked += 1 + have = result.amount(key) if result.has(key) else None + if have != got[key][0]: + misses.append((name, key, got[key][0], have)) + # 안전관리비 A·B 두 값도 원본 비고가 나란히 적음 — 작은 쪽 채택까지 확인. + pair = _RE_SAFETY_AB.search(got.get("safety_management_cost", (0, 0, 0, ""))[3]) + if pair: + checked += 2 + want = (_won(pair.group(1)), _won(pair.group(2))) + have_pair = ( + result.amount("safety_management_cost_a"), + result.amount("safety_management_cost_b"), + ) + if have_pair != want: + # 1원 이내는 절사 자리 차이(B 줄) — 그보다 크면 엔진이 틀린 것. + bucket = ( + rounding if max(abs(h - w) for w, h in zip(want, have_pair)) <= 1 else misses + ) + bucket.append((name, "safety_a_b", want, have_pair)) + assert result.amount("safety_management_cost") == min(have_pair) + assert len(sheets) >= 6, len(sheets) # 실무 6건 + # 54 = 요율이 보이는 줄 48(온전한 셋 ×10 + 빈칸 있는 셋 ×6) + 안전관리비 A·B 짝 6. + assert checked >= 54, checked + assert blank == 12, blank # 원본이 식을 안 보인 줄 — 3건 × 4줄 + assert not misses, misses + # 절사 자리 차 — 지금 아는 것은 영월 안전관리비 B 한 줄뿐. 늘면 여기서 드러남. + assert len(rounding) == 1 and "영월" in rounding[0][0], rounding + + +def test_원가계산서_총공사비까지_사슬_재현(tmp_path) -> None: + """⑤ 사슬 — 순공사원가·일반관리비·이윤·총원가·부가세·도급공사비·관급자재대·총공사비. + + 비고가 다 보이는 원본 셋(봉화·영월·울진 공통)만 사슬에 듦. 이윤은 두 걸음으로 가름 — + ① **자동절사**(총공사비 1,000원 미만 → 이윤에서 ÷1.1 반올림)로 맞는가 — 봉화 339 · 영월 632 + ② 안 맞으면 그 차는 **설계자 명시 이윤조정**(★법대로 8-10, 프로그램이 역산하지 않음). + 울진 공통 905,980 — 예산 맞춤이라 절사 규칙으로는 안 나옴(자동절사는 525). + 조정액은 설계자 입력 칸이라 원본에서 읽어 넣음 — 이 벌이 재는 것은 **그 뒤 사슬**임. + 도급공사비 차는 **원본에만 있는 줄**로만 설명됨 — 영월 국가지점번호판검증수수료 762,000. + + ⚠ 절사는 2026-09-14 부터 **기본이 켜짐**(산림청고시 2025-82호) — 그래서 「안 켠 값」을 볼 때는 + `cut_basis="none"` 으로 **손수 꺼서** 봄. 자동절사 쪽은 기본값 그대로 돌림. + """ + sheets = _cost_sheets() + if not sheets: + pytest.skip("실무 원본 XLSX 가 없음") + full = [s for s in sheets if all(s[1][k][1] is not None for k in _STATUTORY_KEYS if k in s[1])] + assert len(full) >= 3, [s[0] for s in full] + chained, automatic, manual, misses = 0, [], [], [] + for index, (name, got, unknown) in enumerate(full): + data = replace(_cost_input(got, tmp_path / f"chain{index}.json"), cut_basis="none") + plain = calculate_cost(data) + gap = plain.amount("profit_before_adjustment") - got["profit"][0] + cut = calculate_cost(replace(data, cut_basis="grand_total", cut_unit_krw=1000)) + (automatic if cut.amount("profit") == got["profit"][0] else manual).append((name, gap)) + result = calculate_cost(replace(data, profit_adjustment_krw=gap)) + extra = sum((amount for _label, amount in unknown), Decimal(0)) + for key in ( + "material_cost", + "labor_cost", + "expense", + "net_construction_cost", + "general_overhead", + "profit", + "total_cost", + "vat", + "contract_amount", + "owner_supplied_material_total", + "grand_total", + ): + if key not in got: + continue + chained += 1 + have = result.totals.get(key) + if have is None: + have = result.amount(key) if result.has(key) else None + # 도급공사비 아래는 원본에만 있는 줄만큼 낮게 나옴 — 그 몫을 덜어 견줌. + want = got[key][0] - ( + extra if key in ("contract_amount", "grand_total") else Decimal(0) + ) + if have != want: + misses.append((name, key, want, have)) + assert chained >= 30, chained + assert not misses, misses + # 자동절사가 이윤을 그대로 맞추는 원본이 둘, 설계자 조정이 필요한 원본이 하나. + assert len(automatic) == 2, automatic + assert [g for _n, g in automatic] == [Decimal(339), Decimal(632)], automatic + assert [g for _n, g in manual] == [Decimal(905_980)], manual + + +def test_총공사비는_천원_미만_버림이_기본(tmp_path) -> None: + """⑤ 총액 절사 — 실무 6건이 **여섯 다** `000` 으로 앉음(2026-09-14 기본으로 켬). + + 근거 — 산림청고시 제2025-82호 「금액의 단위표준」 「설계서의 총액 · 원 · 1,000 · 미만버림」. + 끄면(`cut_basis="none"`) 여섯 다 끝자리가 남는 것으로 **절사가 실제로 도는지**를 가름. + + ⚠ 잔차 걸음도 ÷1.1 을 해야 앉음(2026-09-14 고침) — 그대로 빼면 울진 신설이 + `654 → 999 → 900 → 910 → 909 …` 로 진동해 끝자리가 남았음. + """ + sheets = _cost_sheets() + if not sheets: + pytest.skip("실무 원본 XLSX 가 없음") + landed, left_over = [], [] + for index, (name, got, _unknown) in enumerate(sheets): + data = _cost_input(got, tmp_path / f"cut{index}.json") # 기본값 그대로 + result = calculate_cost(data) + landed.append((name, result.totals["grand_total"] % 1000)) + off = calculate_cost(replace(data, cut_basis="none")) + left_over.append((name, off.totals["grand_total"] % 1000)) + assert any("2025-82호" in note for note in result.notes), result.notes + assert not [n for n in result.notes if "안 앉음" in n], result.notes + assert [tail for _n, tail in landed] == [Decimal(0)] * len(sheets), landed + assert all(tail for _n, tail in left_over), left_over diff --git a/resources/tester/test_b09_golden_stmate.py b/resources/tester/test_b09_golden_stmate.py index 400bfc3f..e8bb36c5 100644 --- a/resources/tester/test_b09_golden_stmate.py +++ b/resources/tester/test_b09_golden_stmate.py @@ -7,6 +7,7 @@ ④ 중기 시간당 사용료 `중기사용료` 시트 호표 — 손료·운전원·연료·잡품 ✅ ③ 단가산출 Q 식 `단가산출근거` 시트 — 중기 성분 × 1/Q(0.1원) · 머리 원 미만 ✅ ① 일위대가·내역 절사 `일위대가표` 성분 소계 93.5% · `설계내역서` 줄 성분별 전수 ✅ + ⑤ 공사원가계산서 제비율·이윤·부가세·총공사비 — 옆 `test_b09_golden_cost_sheet.py` ⚠ 실무 원본은 **git 안 지식DB**라 어느 창에서든 돎. 원본이 없으면 건너뜀(시험 코드 탓이 아님). ⚠ 값을 여기서 짓지 않음 — 원본 칸을 읽어 **엔진 함수의 입력**으로만 씀. @@ -39,7 +40,19 @@ PRACTICE = ROOT / "resources" / "knowledge" / "original" / "실무문서" def _workbooks() -> tuple[tuple[str, dict[str, list[tuple]]], ...]: """실무 XLSX 마다 쓰는 시트만 값으로 읽어 둠(한 번). `(상대 경로, {시트: 줄들})`.""" openpyxl = pytest.importorskip("openpyxl") - wanted = ("환율및기초자료", "중기사용료", "단가산출근거", "일위대가표", "설계내역서") + wanted = ( + "환율및기초자료", + "중기사용료", + "단가산출근거", + "일위대가표", + "설계내역서", + "재료비수량금액집계표", + "노무비수량금액집계표", + "경비수량금액집계표", + "중기시간금액집계표", + "중기목록표", + "공사원가계산서", + ) found = [] for path in sorted(PRACTICE.rglob("*.xlsx")): if path.name.startswith("~$"): @@ -391,3 +404,40 @@ def test_내역_줄_금액은_성분마다_절사한_합() -> None: misses.append((name, row[1], want, got)) assert len(rows) >= 300, len(rows) assert not misses, (len(misses), misses[:5]) + + +def test_집계표_금액은_반올림_중기는_성분마다_반올림한_합() -> None: + """집계표 넷 — 재료·노무·경비는 `반올림(수량 × 단가)`, 중기는 **성분마다 반올림한 뒤 합**(단가 열 없음). + + 실측 — 자원 셋 279/279 · 중기 131/144(합계 한 번에 반올림 107/144). 중기 짝은 목록표 명칭·규격으로 찾음 + (같은 명칭·규격이 갈래로 둘인 기종은 짝이 흔들림 — 규칙 탓 아님). + """ + from B09_Estimation.B09_Estimation_Lists import machine_summary_amounts + from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at + + resource, machine, machine_hits = 0, 0, 0 + for _name, sheets in _workbooks(): + for sheet in ("재료비수량금액집계표", "노무비수량금액집계표", "경비수량금액집계표"): + for row in sheets.get(sheet, []): + quantity, price, amount = _num(row[3]), _num(row[5]), _num(row[6]) + if quantity is None or price is None or amount is None: + continue + resource += 1 + assert round_at(quantity * price, OutputPlace.RESOURCE_SUMMARY) == amount, row + units = { + (row[1], row[2]): Money3(_num(row[6]), _num(row[5]), _num(row[7])) + for row in sheets.get("중기목록표", []) + if _num(row[4]) is not None + } + for row in sheets.get("중기시간금액집계표", []): + quantity = _num(row[3]) + if quantity is None or (row[1], row[2]) not in units: + continue + machine += 1 + parts = machine_summary_amounts(units[(row[1], row[2])], quantity) + got = (sum(parts.values()), parts["labor"], parts["material"], parts["expense"]) + machine_hits += got == tuple(_num(v) or Decimal(0) for v in row[5:9]) + if not resource: + pytest.skip("실무 원본 XLSX 가 없음") + assert resource >= 250 and machine >= 100, (resource, machine) + assert machine_hits >= machine * 0.9, (machine_hits, machine) diff --git a/resources/tester/test_b09_machine_catalog_names.py b/resources/tester/test_b09_machine_catalog_names.py new file mode 100644 index 00000000..5feaf783 --- /dev/null +++ b/resources/tester/test_b09_machine_catalog_names.py @@ -0,0 +1,60 @@ +"""기계 카탈로그 이름 — 원천 파싱 병(쪽 끝 이름에 다음 쪽 머리글이 붙고 다음 분류 이름이 빔) 되살림. + +2026-09-14 축 C 1장 ① (브레인 차례) · 원문 = 건설공사 표준품셈 제8장 기종 목록 줄. + ① 표 머리글(「시 간 당」·「(10-7)」)이 붙은 이름이 하나도 없음 + ② 빈 이름이 하나도 없음 + ③ 되살린 이름·규격이 원문 차례와 같음 · 취득가는 원천 그대로 +""" + +from __future__ import annotations + +import json +import sys +from decimal import Decimal +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog # noqa: E402 + +CATALOG = load_machine_catalog() + + +def test_표_머리글이_붙은_이름이_없다() -> None: + glued = [ + m.machine_code + for m in CATALOG.machines.values() + if "시 간 당" in m.name or "(10-7)" in m.name + ] + assert glued == [] + + +def test_빈_이름이_없다() -> None: + assert [m.machine_code for m in CATALOG.machines.values() if not m.name.strip()] == [] + + +def test_되살린_이름과_규격() -> None: + expected = { + "5220-0015": ("소형브레이커(전기식)", "1.5㎾"), + "7101-0450": ("고성능 착정기", "335.70㎾"), + "0240-0007": ("유압식 진동콤팩터(굴착기 부착용)", "0.7"), + "3611-0142": ("콘크리트 피니셔(중앙분리대용)", "105.9"), + "6802-0100": ("파일천공전용장비", "100"), + "9070-0020": ("이우선(비자항)", ""), + } + for code, (name, spec) in expected.items(): + machine = CATALOG.machines[code] + assert (machine.name, machine.specification) == (name, spec), code + + +def test_취득가는_원천_그대로() -> None: + raw = json.loads( + (ROOT / "resources/data_cost_input_value/mach_base_2026.json").read_text(encoding="utf-8") + ) + prices = { + r["machine_code"]: r["price_thousand_krw"] + for r in raw["variables"]["mach_price"]["records"] + } + for code in ("5220-0015", "0240-0007", "6802-0040", "7930-0100"): + assert CATALOG.machines[code].price_thousand_krw == Decimal(str(prices[code])) diff --git a/resources/tester/test_b09_material_prices.py b/resources/tester/test_b09_material_prices.py new file mode 100644 index 00000000..0bb875d0 --- /dev/null +++ b/resources/tester/test_b09_material_prices.py @@ -0,0 +1,189 @@ +"""자재 수동 단가(PLAN 1장 Ⓐ · 2026-09-14 브레인 판정). + +지키는 것 + ① 키 = 코드(`AR-M-…`) · 코드 없는 자재만 「이름 규격」 — 조립엔 코드 키만 얹음 + ② 0 이하·수 아님은 안 받음 · 비우면 지움 · 값·출처가 같으면 넣은 날 그대로 + ③ 넣으면 자재 제목(6번 슬롯)이 서고 자원 축 자재 줄이 붙어 재료비가 섬 — 안 넣으면 종전 벌 그대로 + ④ 수동 단가가 닿은 내역 줄 = 금액은 서되 「미확정 N건」 +""" + +from __future__ import annotations + +import sys +from decimal import Decimal +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B09_Estimation.B09_Estimation_MaterialPrices import ( # noqa: E402 + MaterialPriceError, + build_key, + listing, + merge, +) +from B09_Estimation.B09_Estimation_UnitPrice import cached_build # noqa: E402 + +STAKE = "AR-M-dce99e46" # 말뚝 직경4~6㎝ — FP-05-15 에 2개/단위 + + +def test_키와_넣은_날() -> None: + first = merge({}, [{"key": STAKE, "price_krw": "1,500", "source": "견적 A"}], "2026-09-14") + assert first[STAKE]["price_krw"] == "1500" and first[STAKE]["entered_at"] == "2026-09-14" + same = merge(first, [{"key": STAKE, "price_krw": "1500", "source": "견적 A"}], "2026-09-20") + assert same[STAKE]["entered_at"] == "2026-09-14" # 다시 저장해도 안 바뀜 + moved = merge(first, [{"key": STAKE, "price_krw": "1600", "source": "견적 A"}], "2026-09-20") + assert moved[STAKE]["entered_at"] == "2026-09-20" + assert merge(first, [{"key": STAKE, "price_krw": ""}], "2026-09-20") == {} + for bad in ("0", "-3", "abc"): + with pytest.raises(MaterialPriceError): + merge({}, [{"key": STAKE, "price_krw": bad}], "2026-09-14") + named = merge(first, [{"key": "각재 50×50", "price_krw": "900000"}], "2026-09-14") + assert build_key(named) == ((STAKE, "1500", "견적 A"),) # 이름 키는 조립에 안 얹음 + + +def test_넣으면_재료비가_서고_안_넣으면_종전_그대로() -> None: + base = cached_build() + assert base.book.resolve("B-FP-05-15").material == 0 + assert STAKE not in base.book.titles and not base.manual_materials + built = cached_build(material_prices=((STAKE, "1500", "견적 A"),)) + title = built.book.titles[STAKE] + assert (title.slots[5], title.slot_pages[5], title.unit) == (Decimal("1500"), "견적 A", "개") + assert built.book.resolve("B-FP-05-15").material == Decimal("3000") # 2개 × 1,500 + assert built.book.resolve("B-FP-05-15").labor == base.book.resolve("B-FP-05-15").labor + assert "FP-05-15" in built.material_uses[STAKE] + + +def test_내역_줄은_미확정으로_선다() -> None: + from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill + + built = cached_build(material_prices=((STAKE, "1500", "견적 A"),)) + payload = { + "work_items": [ + { + "work_item_code": "FP-05-15", + "name": "말뚝박기", + "unit": built.book.titles["B-FP-05-15"].unit, + "quantity": 10, + "in_bill": True, + } + ], + "materials": [], + } + row = next(r for r in build_bill(payload, build=built).rows if r.name == "말뚝박기") + assert row.material_krw == Decimal("30000") and row.unconfirmed == 1 + result = build_bill(payload, build=built) + assert result.unconfirmed == [{"name": "말뚝박기", "code": "B-FP-05-15", "count": 1}] + plain = build_bill(payload, build=cached_build()) + assert not plain.unconfirmed + + +def _자재(name: str, spec: str, total: str, supply: str, source: list[str]) -> dict: + return { + "material_name": name, + "spec": spec, + "unit": "㎥", + "net_amount": total, + "total_amount": total, + "supply_type": supply, + "source_structure": source, + } + + +def test_사급_자재총괄_줄은_본체_자재_줄로_서고_관급은_수량만() -> None: + """Ⓐ-2 — 셈(㉠ 0 · ㉡ 9 · ㉢ 0) 뒤 브레인 판정. 도급 재료비에 들고 미확정으로 셈.""" + from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill + + payload = { + "work_items": [], + "materials": [ + _자재("각재", "50×50", "0.2684", "contractor_supplied", ["비탈 규준틀"]), + _자재("판재", "T12", "0.1769", "contractor_supplied", ["비탈 규준틀"]), + _자재("채움콘크리트", "180", "3.4", "owner_supplied", ["돌쌓기(찰)"]), + ], + } + prices = {"각재 50×50": {"price_krw": "650000"}, "채움콘크리트 180": {"price_krw": "90000"}} + result = build_bill(payload, build=cached_build(), material_prices=prices) + group = next(r for r in result.rows if r.name == "자재(사급)") + rows = [r for r in result.rows if r.item_no.startswith(f"{group.item_no}-")] + assert [(r.name, r.spec) for r in rows] == [("각재", "50×50")] # 판재 단가 없음 · 관급 안 올림 + # 수량을 1-2-2 자리(㎥ 2자리)로 반올림해 확정한 뒤 곱함 — 0.2684 → 0.27 × 650,000 + assert rows[0].quantity == Decimal("0.27") and rows[0].material_krw == Decimal("175500") + assert rows[0].unconfirmed == 1 and result.direct_material_krw == Decimal("175500") + assert any(m["name"] == "판재 T12" and "단가 없음" in m["reason"] for m in result.missing) + sheet = result.material_sheet + assert ( + sheet.contractor_rows[0].manual and not sheet.owner_rows[0].manual + ) # 관급엔 수동 단가 안 씀 + plain = build_bill(payload, build=cached_build()) + assert not any(r.name == "자재(사급)" for r in plain.rows) and plain.direct_material_krw == 0 + + +def test_같은_구조물_일위대가_자재와_겹치면_안_올리고_사유() -> None: + """이중계상 가드 — 코드가 있으면 코드로, 없을 때만 이름으로(막는 쪽이라 틀려도 과소).""" + from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill + from B09_Estimation.B09_Estimation_BillOfQuantities_Materials import overlap + + built = cached_build(material_prices=((STAKE, "1500", ""),)) + unit = built.book.titles["B-FP-05-15"].unit + payload = { + "work_items": [ + {"work_item_code": "FP-05-15", "name": "말뚝박기", "unit": unit, "quantity": 10} + ], + "materials": [_자재("말뚝", "", "20", "contractor_supplied", ["말뚝박기"])], + } + result = build_bill(payload, build=built, material_prices={"말뚝": {"price_krw": "1500"}}) + assert not any(r.name == "자재(사급)" for r in result.rows) + hit = next(m for m in result.missing if m.get("blocked_kind") == "double_count_suspect") + assert "말뚝" in hit["reason"] + inside = [(STAKE, "말뚝"), ("", "시멘트 510 kg — 자재 단가 층 없음")] + assert overlap("시멘트", "", inside).startswith("시멘트") + assert overlap("말뚝", "AR-M-00000000", inside) == "" # 코드가 있으면 코드로만 + + +def test_목록은_자원_축_자재와_사라진_저장_줄() -> None: + built = cached_build() + stored = { + STAKE: {"price_krw": "1500"}, + "AR-M-00000000": {"price_krw": "7", "name": "옛 자재"}, + } + materials = [_자재("각재", "50×50", "0.2684", "contractor_supplied", ["비탈 규준틀"])] + rows = { + row["key"]: row for row in listing(built, {**stored, "못": {"price_krw": "3"}}, materials) + } + assert rows[STAKE]["name"] == "말뚝" and rows[STAKE]["price_krw"] == "1500" + assert rows["AR-M-00000000"]["missing"] is True # 조용히 안 버림 + assert rows["AR-M-b0853497"]["price_krw"] is None + sheet_row = rows["각재 50×50"] + assert (sheet_row["name"], sheet_row["spec"], sheet_row["origin"]) == ( + "각재", + "50×50", + "material_sheet", + ) + assert sheet_row["supply_type"] == "contractor_supplied" and rows["못"]["missing"] is True + + +def test_내역_수량은_자리로_반올림해_확정한_뒤_금액은_버림() -> None: + """1-2-2 [주]① 수량 반올림 → 금액 버림 — 둘을 섞지 않음(2026-09-14 브레인 판정). + + 인계 전정밀 0.28181999…96 로 곱하면 183,182 원(1원 샘) — 확정 수량 0.28 × 650,000 = 182,000. + """ + from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill + from B09_Estimation.B09_Estimation_QuantityDigits import digits_for + + payload = { + "work_items": [], + "materials": [ + _자재("각재", "50×50", "0.28181999999999996", "contractor_supplied", ["비탈 규준틀"]) + ], + } + result = build_bill( + payload, build=cached_build(), material_prices={"각재 50×50": {"price_krw": "650000"}} + ) + row = next(r for r in result.rows if r.name == "각재") + assert (row.quantity, row.amount_krw) == (Decimal("0.28"), Decimal("182000")) + assert row.as_dict()["quantity"] == "0.28" # 계약·기성이 읽는 수량도 확정값 + assert digits_for("낯선종목", "개") == 2 # 표에 없는 종목은 우리 기본 2자리 + assert digits_for("돌쌓기(찰)", "㎡") == 1 and digits_for("철근", "kg") == 0 diff --git a/resources/tester/test_b09_misc_material.py b/resources/tester/test_b09_misc_material.py index 978be03a..903e32c3 100644 --- a/resources/tester/test_b09_misc_material.py +++ b/resources/tester/test_b09_misc_material.py @@ -129,13 +129,14 @@ def test_칸을_저장했다_지웠다_할_수_있다(tmp_path, monkeypatch) -> 돌아갈 수 없다. """ from B09_Estimation import B09_Estimation_Router as router + from B09_Estimation import B09_Estimation_Router_Factors as factors project_id = uuid4() monkeypatch.setattr(router, "_project_root_of", _fake_root(str(tmp_path))) def _put(value: str): - body = router.FactorChoiceBody(misc_material_percent=value) - got = asyncio.run(router.put_factor_choices(project_id, body)) + body = factors.FactorChoiceBody(misc_material_percent=value) + got = asyncio.run(factors.put_factor_choices(project_id, body)) return got.status_code, json.loads(got.body.decode()) from common_util.common_util_project_settings import estimation_settings diff --git a/resources/tester/test_b09_operating_paren_spec.py b/resources/tester/test_b09_operating_paren_spec.py new file mode 100644 index 00000000..eb458698 --- /dev/null +++ b/resources/tester/test_b09_operating_paren_spec.py @@ -0,0 +1,34 @@ +"""운전경비 표 규격 칸의 괄호 딸림값 — 2026-09-14 공기압축기 손료(브레인 차례). + +품셈 8-4 운전경비 표는 규격 칸에 딸림값을 괄호로 붙여 적음 — +「50ton/hr(93㎾) 100 (155) 150 (260)」 · 크레인 「10ton (0.29) 15 (0.38)」. +파서가 괄호 토큰도 규격 하나로 세어 코드 수와 안 맞는다며 **줄째 버렸음** → 공기압축기(5205) +여섯 · 크레인 26 · 콘크리트 기계 등이 운전경비를 잃고 기계 층이 「미완성」으로 섬. +9-19-1 연암·보통암 · 9-19-3 의 공기압축기 3.5 가 그래서 「일부만」 으로 막혔음. +""" + +from __future__ import annotations + +from B09_Estimation.B09_Estimation_MachineOperating import load_operating_records +from B09_Estimation.B09_Estimation_UnitPrice import cached_build, detail_of + + +def test_괄호_딸림값은_규격_개수에_안_셈_공기압축기_운전경비가_섬() -> None: + result = load_operating_records() + records = {record.machine_code: record for record in result.records} + compressor = records.get("5205-0035") + assert compressor is not None, result.dropped_tables + assert (str(compressor.fuel_liters_per_hour), compressor.fuel_kind) == ("6.2", "경유") + assert str(compressor.misc_material_percent) == "16" + assert str(compressor.operator_person_days) == "1" + assert not [t for t in result.dropped_tables if t.startswith("8-4-6")], result.dropped_tables + + +def test_공기압축기_층이_서서_토사면_고르기_암_갈래에_붙음() -> None: + build = cached_build() + assert "5205-0035" not in build.incomplete_machines + rows = { + r["ref_code"]: float(r["quantity"]) + for r in detail_of(build, "B-FP-09-19-01#절토면·연암")["rows"] + } + assert rows.get("X-5205-0035") == 0.125, rows diff --git a/resources/tester/test_b09_progress.py b/resources/tester/test_b09_progress.py new file mode 100644 index 00000000..3d60eea0 --- /dev/null +++ b/resources/tester/test_b09_progress.py @@ -0,0 +1,237 @@ +"""기성 단계 — 계약 → 기성내역 · 기성 제잡비 계산서 **구조**가 서는가 (PLAN 12장 · 2026-09-14 배정). + +⚠ 값이 맞는지는 못 잰다 — 기성 표본 0건(STmate 27번 §9 · 35번). 여기서 재는 것: + ① ⭐ 제잡비 줄 = 금회직접공사비 × 계약잡비율(도급액 ÷ 계약 직접공사비) + — 줄마다 밑수×요율 다시 안 셈 + ② 계약 · 전회(앞 회차 금회의 합) · 금회 · 누계 네 칸과 각 비율 · 기성(%) · 잔량 + ③ 부가세 넷 — 직접입력 · 공급가액 · 재료비 · 재료비+산출경비 + ④ 계약 줄(입력) 불변 · 분리발주 폐기물은 제잡비 밖 · 틀린 칸 거름 +""" + +from __future__ import annotations + +import copy +import sys +from decimal import Decimal +from pathlib import Path +from types import SimpleNamespace + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B09_Estimation.B09_Estimation_Engine_Cost import CostLine, CostResult # noqa: E402 +from B09_Estimation.B09_Estimation_Progress import ( # noqa: E402 + clean_settings, + completion_sheet, + progress_sheet, +) + + +def _contract_row(item_no: str, qty: str, unit: tuple[int, int, int]) -> dict: + total = sum(unit) * int(qty) + return { + "item_no": item_no, + "is_group": False, + "in_bill": True, + "quantity": qty, + "contract_unit_material_krw": str(unit[0]), + "contract_unit_labor_krw": str(unit[1]), + "contract_unit_expense_krw": str(unit[2]), + "contract_amount_krw": str(total), + } + + +CONTRACT_ROWS = [ + {"item_no": "1", "is_group": True, "in_bill": True}, + _contract_row("1.1", "100", (1000, 2000, 500)), # 350,000 + _contract_row("1.2", "10", (5000, 0, 0)), # 50,000 → 계약 직접공사비 400,000 +] + + +def _cost(separate_waste: bool = False) -> tuple[SimpleNamespace, CostResult]: + result = CostResult() + for key, amount in ( + ("indirect_labor_cost", 20000), + ("industrial_accident_insurance", 7000), + ("waste_disposal", 5000), + ("general_overhead", 24000), + ("profit", 30000), + ("vat", 48100), + ): + result.lines.append( + CostLine(key, key, "", Decimal(amount), None, Decimal(0), Decimal(amount)) + ) + data = SimpleNamespace(waste_separate_order=separate_waste, indirect_material_krw=Decimal(0)) + return data, result + + +SETTINGS = { + "rounds": [ + {"quantities": {"1.1": "40", "1.2": "2"}, "vat_mode": "supply"}, + {"quantities": {"1.1": "30"}, "vat_mode": "material"}, + ] +} + + +def _sheet(settings=SETTINGS, round_no=None, separate_waste=True) -> dict: + data, result = _cost(separate_waste) + return progress_sheet(CONTRACT_ROWS, data, result, clean_settings(settings)[0], round_no) + + +def test_제잡비는_금회직접공사비_곱하기_계약잡비율() -> None: + before = copy.deepcopy(CONTRACT_ROWS) + sheet = _sheet() + assert CONTRACT_ROWS == before # 계약 줄 불변 + items = {item["key"]: item for item in sheet["items"]} + labor = items["indirect_labor_cost"] + # 도급액 20,000 ÷ 계약 직접 400,000 = 5% · 1회 150,000 → 7,500 · 2회 105,000 → 5,250 + assert (labor["contract_ratio_pct"], labor["previous_krw"], labor["current_krw"]) == ( + "5.000", + "7500", + "5250", + ) + assert (labor["cumulative_krw"], labor["cumulative_pct"]) == ("12750", "63.750") + # 7,000 × 105,000 ÷ 400,000 = 1,837.5 → 회차마다 원 미만 절사 + assert items["industrial_accident_insurance"]["current_krw"] == "1837" + assert "waste_disposal" not in items # 분리발주면 도급 밖 + assert sheet["contract_overhead_ratio_pct"] == "20.250" # 81,000 ÷ 400,000 + + +def test_네_칸과_기성율_잔량() -> None: + sheet = _sheet() + row = {r["item_no"]: r for r in sheet["rows"]}["1.1"] + assert ( + row["progress_previous_quantity"], + row["progress_current_quantity"], + row["progress_cumulative_quantity"], + ) == ("40", "30", "70") + assert (row["progress_cumulative_amount_krw"], row["progress_pct"]) == ("245000", "70.000") + assert row["progress_remaining_quantity"] == "30" + group = sheet["rows"][0] + assert group["progress_cumulative_amount_krw"] == "255000" # 245,000 + 1.2 의 10,000 + summary = {s["key"]: s for s in sheet["summary"]} + assert (summary["direct"]["previous_krw"], summary["direct"]["current_krw"]) == ( + "150000", + "105000", + ) + assert summary["direct"]["cumulative_pct"] == "63.750" + + +def test_부가세_넷() -> None: + summary = {s["key"]: s for s in _sheet()["summary"]} + # 1회 공급가액 180,375 × 10% = 18,037 · 2회 재료비 30,000 × 10% = 3,000 + assert (summary["supply"]["previous_krw"], summary["vat"]["previous_krw"]) == ( + "180375", + "18037", + ) + assert summary["vat"]["current_krw"] == "3000" + coop = {"rounds": [{"quantities": {"1.1": "40"}, "vat_mode": "forest_coop"}]} + vat = {s["key"]: s for s in _sheet(coop)["summary"]}["vat"]["current_krw"] + assert vat == "6000" # (재료비 40,000 + 산출경비 20,000) × 10% + manual = { + "rounds": [{"quantities": {"1.1": "40"}, "vat_mode": "manual", "vat_manual_krw": "12345"}] + } + assert {s["key"]: s for s in _sheet(manual)["summary"]}["vat"]["current_krw"] == "12345" + + +def test_회차를_고르면_그_앞이_전회() -> None: + first = _sheet(round_no=1) + row = {r["item_no"]: r for r in first["rows"]}["1.1"] + assert (row["progress_previous_quantity"], row["progress_current_quantity"]) == ("0", "40") + assert first["round"] == 1 and first["round_count"] == 2 + + +def test_계약_수량_넘으면_알림과_분리발주_아니면_폐기물도_제잡비() -> None: + over = {"rounds": [{"quantities": {"1.2": "12"}, "vat_mode": "supply"}]} + row = {r["item_no"]: r for r in _sheet(over)["rows"]}["1.2"] + assert row["progress_remaining_quantity"] == "-2" and "넘음" in row["progress_note"] + items = {item["key"] for item in _sheet(separate_waste=False)["items"]} + assert "waste_disposal" in items + + +def _items(sheet: dict) -> dict: + return {item["key"]: item for item in sheet["items"]} + + +def _summary(sheet: dict) -> dict: + return {s["key"]: s for s in sheet["summary"]} + + +def test_계약잡비율_직접입력이_이기고_사유가_없으면_거른다() -> None: + settings = { + **SETTINGS, + "rate_overrides": {"indirect_labor_cost": {"rate_pct": "10", "reason": "계약서 제잡비율"}}, + } + labor = _items(_sheet(settings))["indirect_labor_cost"] + assert (labor["contract_ratio_pct"], labor["default_ratio_pct"]) == ("10.000", "5.000") + assert labor["current_krw"] == "10500" and labor["override"]["reason"] == "계약서 제잡비율" + _, errors = clean_settings({"rate_overrides": {"profit": {"rate_pct": "7", "reason": " "}}}) + assert len(errors) == 1 + + +def test_단일율_곱과_벌어진_차이를_알린다() -> None: + # 1.2 × 0.1998 = 999원 → 줄별 49 + 17 + 59 + 74 = 199 · 단일율 20.25% 곱 202 → 3원 + sheet = _sheet({"rounds": [{"quantities": {"1.2": "0.1998"}}]}) + assert sheet["single_rate_gap_krw"]["current"] == "3" + assert any("단일율 곱과 금회 3원" in note for note in sheet["notes"]) + + +def test_공급가액_절사와_총공사비_절사는_이윤에서() -> None: + first = {"quantities": {"1.1": "40", "1.2": "2"}, "vat_mode": "supply"} + cut = _sheet({"rounds": [{**first, "supply_cut_krw": "100"}]}) + # 공급가액 180,375 → 180,300 · 이윤 11,250 − 75 · 부가세 18,030 + assert _items(cut)["profit"]["current_krw"] == "11175" + assert (_summary(cut)["supply"]["current_krw"], _summary(cut)["vat"]["current_krw"]) == ( + "180300", + "18030", + ) + total = _sheet({"rounds": [{**first, "total_cut_krw": "1000"}]}) + # 총공사비 198,412 → 412 ÷ 1.1 = 375 을 이윤에서 → 공급가액 180,000 · 부가세 18,000 + assert _summary(total)["total"]["current_krw"] == "198000" + assert _items(total)["profit"]["current_krw"] == "10875" + + +def test_이윤금액_직접입력과_사정_칸() -> None: + entry = { + "quantities": {"1.1": "40", "1.2": "2"}, + "profit_manual_krw": "10000", + "assessed": {"1.1": "130000"}, + } + sheet = _sheet({"rounds": [entry]}) + assert _items(sheet)["profit"]["current_krw"] == "10000" + assert _summary(sheet)["supply"]["current_krw"] == "179125" + row = {r["item_no"]: r for r in sheet["rows"]}["1.1"] + assert row["progress_assessed_krw"] == "130000" # 칸만 — 금액에 안 섞임 + assert row["progress_current_amount_krw"] == "140000" + + +def test_준공은_마지막_회차_누계를_옮기기만() -> None: + progress = _sheet() + before = copy.deepcopy(progress) + done = completion_sheet(progress) + assert progress == before # 기성 한 장 불변 + row = {r["item_no"]: r for r in done["rows"]}["1.1"] + assert (row["contract_amount_krw"], row["completion_amount_krw"]) == ("350000", "245000") + lines = {line["key"]: line for line in done["lines"]} + summary = {s["key"]: s for s in progress["summary"]} + assert lines["total"]["completion_krw"] == summary["total"]["cumulative_krw"] + assert lines["indirect_labor_cost"]["completion_krw"] == "12750" and done["from_round"] == 2 + + +def test_기성_회차가_없으면_준공금액을_비운다() -> None: + done = completion_sheet(_sheet({"rounds": []})) + assert all(line["completion_krw"] is None for line in done["lines"]) + assert done["notes"] and done["from_round"] == 0 + + +def test_틀린_칸은_거른다() -> None: + _, errors = clean_settings( + { + "rounds": [ + {"quantities": {"1.1": "-1"}, "vat_mode": "supply"}, + {"quantities": {}, "vat_mode": "manual", "vat_manual_krw": ""}, + {"quantities": {}, "vat_mode": "없는방식"}, + ] + } + ) + assert len(errors) == 3 diff --git a/resources/tester/test_b09_quantity_digits_golden.py b/resources/tester/test_b09_quantity_digits_golden.py new file mode 100644 index 00000000..e241fe2e --- /dev/null +++ b/resources/tester/test_b09_quantity_digits_golden.py @@ -0,0 +1,29 @@ +"""수량 자리 맞춤 골든 대조 — STmate 실무 원본 내역 수량이 우리 자리 규칙으로 **안 바뀌어야** 함. + +2026-09-14 브레인 판정 — 내역 수량은 1-2-2 자리로 반올림해 확정한 뒤 금액(버림). 골든 금액 시험 +(`test_b09_golden_stmate`)은 원본 수량을 `bill_line` 에 바로 넣어 이 길을 안 거치므로, 원본 수량 +자체로 자리 규칙을 따로 잰다(321줄 · 철근 ton 소수 3자리가 이 대조에서 드러남). +""" + +from __future__ import annotations + +import pytest +from test_b09_golden_stmate import _bill_rows, _num + +from B09_Estimation.B09_Estimation_QuantityDigits import round_quantity + + +def test_실무_원본_내역_수량이_자리_규칙으로_안_바뀐다() -> None: + rows = _bill_rows() + if not rows: + pytest.skip("실무 원본 XLSX 가 없음") + changed = [] + for name, row in rows: + quantity, unit = _num(row[3]), str(row[4] or "").strip() + if quantity is None or unit == "%": + continue + settled, digits = round_quantity(quantity, str(row[1] or ""), unit, str(row[2] or "")) + if settled != quantity: + changed.append((name, row[1], unit, str(quantity), digits)) + assert len(rows) >= 300, len(rows) + assert not changed, (len(changed), changed[:5]) diff --git a/resources/tester/test_b09_rate_override.py b/resources/tester/test_b09_rate_override.py new file mode 100644 index 00000000..2a791f31 --- /dev/null +++ b/resources/tester/test_b09_rate_override.py @@ -0,0 +1,119 @@ +"""프로젝트 요율 덮어쓰기 — 프로젝트 단위 · 사유 저장 · 기본값↔고친 값 (PLAN 6장 새 절). + +⚠ 겨누는 것 + ① 마스터 요율판은 **안 바뀜**(덮어쓰기는 복사본에만) + ② 사유 없는 줄·틀린 주소·같은 칸 두 번은 거름 + ③ 덮어쓴 요율이 계산에 실제로 걸림 · 안 쓴 프로젝트는 값이 그대로 + ④ 요율표 칸이 기본값·지금 값·주소를 함께 싣고, 그 주소를 그대로 돌려주면 저장됨 +""" + +from __future__ import annotations + +import sys +from dataclasses import replace +from decimal import Decimal +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B09_Estimation.B09_Estimation_Engine_Cost import CostInput, calculate_cost # noqa: E402 +from B09_Estimation.B09_Estimation_RateOverride import ( # noqa: E402 + apply_overrides, + clean_overrides, +) +from B09_Estimation.B09_Estimation_Rates import RateLookupError, load_rate_dataset # noqa: E402 +from B09_Estimation.B09_Estimation_RateTable import rate_sections # noqa: E402 + +BASE = CostInput( + direct_material_krw=Decimal(123_456_789), + direct_labor_krw=Decimal(234_567_891), + direct_expense_krw=Decimal(45_678_912), + estimated_price_krw=Decimal(300_000_000), # 50억 미만 구간 +) +OVERHEAD = { + "variable": "rate_overhead", + "table": "civil_landscape_industrial", + "match": {"estimated_price_bracket": "lt_5_billion"}, + "rate_percent": "7.5", + "reason": "발주처 지침(시험)", +} + + +def test_마스터는_안_바뀐다() -> None: + master = load_rate_dataset() + before = master.variables["rate_overhead"]["civil_landscape_industrial"][0]["rate_percent"] + changed = apply_overrides(master, [OVERHEAD]) + assert ( + changed.variables["rate_overhead"]["civil_landscape_industrial"][0]["rate_percent"] == 7.5 + ) + assert ( + master.variables["rate_overhead"]["civil_landscape_industrial"][0]["rate_percent"] == before + ) + assert ( + load_rate_dataset().variables["rate_overhead"]["civil_landscape_industrial"][0][ + "rate_percent" + ] + == before + ) + + +def test_덮어쓴_요율이_계산에_걸리고_안_쓰면_그대로() -> None: + plain = calculate_cost(BASE) + again = calculate_cost(replace(BASE, rate_overrides=())) + assert plain.totals == again.totals + changed = calculate_cost(replace(BASE, rate_overrides=(OVERHEAD,))) + assert changed.line("general_overhead").rate_percent == Decimal("7.5") + assert changed.amount("general_overhead") < plain.amount("general_overhead") + assert any("요율 덮어쓰기 1건" in note and "발주처 지침" in note for note in changed.notes) + + +def test_한_값짜리_요율도_덮어쓴다() -> None: + sanjae = { + "variable": "rate_sanjae", + "table": None, + "match": {}, + "rate_percent": "3.7", + "reason": "고시 개정", + } + result = calculate_cost(replace(BASE, rate_overrides=(sanjae,))) + assert result.line("industrial_accident_insurance").rate_percent == Decimal("3.7") + + +def test_사유_주소_중복을_거른다() -> None: + master = load_rate_dataset() + kept, errors = clean_overrides( + [ + {**OVERHEAD, "reason": " "}, + {**OVERHEAD, "match": {"estimated_price_bracket": "no_such"}}, + {**OVERHEAD, "rate_percent": "-1"}, + OVERHEAD, + OVERHEAD, + ], + master, + ) + assert len(kept) == 1 and kept[0]["rate_percent"] == "7.5" + assert len(errors) == 4 + assert any("사유" in e for e in errors) and any("두 번" in e for e in errors) + + +def test_주소가_안_맞으면_계산이_멈춘다() -> None: + stale = {**OVERHEAD, "match": {"estimated_price_bracket": "gone"}} + with pytest.raises(RateLookupError): + calculate_cost(replace(BASE, rate_overrides=(stale,))) + + +def test_요율표_칸이_기본값과_주소를_싣고_그대로_돌려주면_저장된다() -> None: + master = load_rate_dataset() + current = apply_overrides(master, [OVERHEAD]) + sections = rate_sections(current, master) + cells = [cell for s in sections for row in s["rows"] for cell in row if isinstance(cell, dict)] + assert cells and all({"value", "default", "overridden", "address"} <= set(c) for c in cells) + changed = [c for c in cells if c["overridden"]] + assert len(changed) == 1 and (changed[0]["value"], changed[0]["default"]) == (7.5, 8.0) + # 화면이 받은 주소 그대로 새 값·사유를 붙여 돌려주면 모두 저장 가능해야 함. + raw = [{**c["address"], "rate_percent": "1", "reason": "왕복"} for c in cells] + kept, errors = clean_overrides(raw, master) + assert not errors and len(kept) == len(cells) diff --git a/resources/tester/test_b09_spec_column_table.py b/resources/tester/test_b09_spec_column_table.py new file mode 100644 index 00000000..270a8ba6 --- /dev/null +++ b/resources/tester/test_b09_spec_column_table.py @@ -0,0 +1,62 @@ +"""규격이 열로 선 표(관부설 12-11) — 규격 칸·비고를 읽어 줄은 서고 못 넣는 줄은 까닭으로 드러남. + +2026-09-14 축 C 1장 ② (브레인 차례 · 판정 불필요). 원문 = 산림사업 표준품셈 12-11-1·2·3. + ① 원문 비고 「별산」·「별도계산」·「설계수량」(〃 · 병합 칸 포함)은 **안 넣는 줄**로 까닭을 남김 + ② 규격 칸(「1:2」)과 관경 열(「∅800mm」)이 조인 키 — 접합몰탈·고무링이 자재 줄로 섬 + ③ 기종이 여럿인 크레인은 **고르지 않고** 「규격 미정 · 원문 규격 10ton」 + ④ 옆 칸 규격 「40.64㎝」 = 카탈로그 절단기 40.64 +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B09_Estimation.B09_Estimation_ResourceAxis import ( # noqa: E402 + build_resource_axis, + load_combined_catalog, + load_work_item_master, +) + +AXIS = build_resource_axis(load_work_item_master(), load_combined_catalog()) + + +def _unmatched(code: str) -> dict[str, str]: + return {u.cell: u.reason for u in AXIS.unmatched if u.work_item_code == code} + + +def _rows(code: str) -> list: + return [r for r in AXIS.rows if r.work_item_code == code] + + +def test_원문_비고대로_안_넣는_줄은_까닭을_남긴다() -> None: + vr = _unmatched("FP-12-11-01") + assert "별도계산" in vr["VR관"] and "설계수량" in vr["기초콘크리트"] + assert "설계수량" in vr["거 푸 집"] # 「〃」 + hume = _unmatched("FP-12-11-02") + assert "별산" in hume["흄 관"] and "설계수량" in hume["거 푸 집"] # 병합 칸 + corrugated = _unmatched("FP-12-11-03") + assert "필요시적용" in corrugated["커플링밴드"] and "설계수량" in corrugated["모래부설"] + + +def test_규격_칸과_관경_열이_조인_키() -> None: + rings = { + r.variant: r.resource_spec for r in _rows("FP-12-11-01") if r.resource_name == "고무링" + } + assert rings == {"∅800mm": "∅800mm", "∅1000mm": "∅1000mm", "∅1200mm": "∅1200mm"} + mortar = [r for r in _rows("FP-12-11-02") if r.resource_name == "접합몰탈"] + assert {r.resource_spec for r in mortar} == {"1:2"} and len(mortar) == 3 + + +def test_크레인은_고르지_않고_원문_규격을_보인다() -> None: + reason = _unmatched("FP-12-11-03")["크레인"] + assert "규격 미정" in reason and "원문 규격 5ton" in reason + assert not any(r.resource_name.startswith("크레인") for r in _rows("FP-12-11-03")) + + +def test_절단기_40_64cm_는_카탈로그_40_64() -> None: + cutters = [r for r in _rows("FP-12-11-02") if r.resource_name == "절단기"] + assert [(r.resource_code, str(r.amount)) for r in cutters] == [("7620-0003", "0.93")] diff --git a/resources/tester/test_b09_table_reading_2.py b/resources/tester/test_b09_table_reading_2.py new file mode 100644 index 00000000..399ee51d --- /dev/null +++ b/resources/tester/test_b09_table_reading_2.py @@ -0,0 +1,168 @@ +"""표 읽기 ②(2026-09-14 · PLAN 1장) — 「못 붙은 줄」 로 잘못 서던 자리를 **상태가 움직이는지**로 잼. + +9-21 제근 고르기형 표 안내 글이 `unmatched` 에 들어가 못 붙은 줄로 섬 — 밑수는 사용자 확정 + (건설품셈 1,000㎡당 빌림)이라 안내는 비고(`known_gap_note`)로 옮김(사유는 안 사라짐) +12-2 표면 마무리 원본이 12-17-1 [주] 문장을 절 제목으로 읽어 표 7장·가짜 갈래 #설치·#철거 가 붙음 +""" + +from __future__ import annotations + +from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note +from B09_Estimation.B09_Estimation_ResourceAxis import load_work_item_master +from B09_Estimation.B09_Estimation_UnitPrice import cached_build + + +def test_제근은_못_붙은_줄이_아니고_교차참조_사유는_비고에_남음() -> None: + build = cached_build() + assert not build.unattached.get("FP-09-21"), build.unattached.get("FP-09-21") + assert sum(1 for code in build.book.titles if code.startswith("B-FP-09-21#")) == 6 + note = known_gap_note("FP-09-21") + assert "교차 참조" in note and "1,000㎡당" in note + + +def test_표면_마무리엔_미장공_표만_있고_가짜_갈래가_없음() -> None: + nodes = {n["work_item_code"]: n for n in load_work_item_master()["work_items"]} + assert [t["pum_table_id"] for t in nodes["FP-12-02"]["tables"]] == ["F0334"] + assert "F0356" in [t["pum_table_id"] for t in nodes["FP-12-17-01"]["tables"]] + build = cached_build() + assert [c for c in build.book.titles if c.startswith("B-FP-12-02")] == ["B-FP-12-02"] + assert not build.unattached.get("FP-12-02") + + +def test_육상_발파암_1_3m_들어내기는_0_1m_로_읽고_고른_값_버린_값을_사유에() -> None: + """③ 원문 「육상 발파암(1~2m)와 동일」(고시 5189·5201줄)은 9-13-14 자기 참조 — 같은 절 암절취· + 용수 발파암이 모두 「(0~1m)와 동일」 이라 0~1m(9-13-13)로 읽음(2026-09-14 브레인 승인).""" + build = cached_build() + for code in ("FP-09-13-14", "FP-09-13-15"): + assert code not in build.partial_ratio and code not in build.component_gaps, code + # 들어내기 참조 줄은 풀림 — 남는 것은 치즐뿐(기본 조립엔 치즐 단가가 없어 종전대로) + left = [label for label in build.unattached.get(code, []) if "치즐" not in label] + assert not left, left + source = build.factor_sources.get(code, "") + assert "육상 발파암(0~1m)" in source and "자기 참조" in source, source + + +def test_비탈면_고르기_발파암_소형브레이커는_1_0_규격으로_코드에_이어짐() -> None: + """④ 9-19-3 값 「(2.45+3.05)/2/10㎡」 ← 9-19-1 표 ← [주]① 「소형브레이커는 1㎥/분」 — + 값의 출처가 규격을 정함(2026-09-14 브레인 승인). 범위 별칭이 **코드로** 곧장 이음(이름만 + 돌려주면 규격 후보 넷이 다시 흐려짐). 「어어호스」 는 원문 오기 + 카탈로그에 호스 없음.""" + from B09_Estimation.B09_Estimation_ResourceAxis import ( + build_resource_axis, + load_combined_catalog, + ) + + axis = build_resource_axis(load_work_item_master(), load_combined_catalog()) + rows = [ + (r.resource_code, str(r.amount)) for r in axis.rows if r.work_item_code == "FP-09-19-03" + ] + assert ("5210-0010", "0.275") in rows, rows + left = [u.cell for u in axis.unmatched if u.work_item_code == "FP-09-19-03"] + assert left == ["어어호스(3/4인치)"], left + + +def test_토사면_고르기는_두_표가_섞여_값이_불완전하다고_먼저_말함() -> None: + """⑤ 사유 먼저(2026-09-14 브레인) — 원문은 절토면·성토면 두 표인데 섞여 보통인부 한 줄만 + 섰었음(조용히 틀린 값). ⑤ 본작업(같은 날)으로 두 표를 갈래로 읽어 「불완전」 사유와 섞인 + 본표(B-FP-09-19-01)가 함께 걷힘 — 갈래 없는 섞인 값이 다시 서면 빨강.""" + assert "불완전" not in known_gap_note("FP-09-19-01") + assert "B-FP-09-19-01" not in cached_build().book.titles + + +#: ⑤ 9-19-1 원문 L5414~5436 — (단위: 10㎡당)이라 ㎡당 = 표값 ÷ 10. +CUT = "B-FP-09-19-01#절토면·" # 갈래 키는 공백을 지움 +FILL = "B-FP-09-19-01#성토면·" + + +def _per_m2(code: str) -> dict[str, float]: + """자원 줄(비율 줄 「%」 뺌) → ㎡당 수량.""" + from B09_Estimation.B09_Estimation_UnitPrice import detail_of + + rows = detail_of(cached_build(), code)["rows"] + return {str(r["ref_code"]): float(r["quantity"]) for r in rows if r.get("unit") != "%"} + + +def test_토사면_고르기_절토면은_토질_여섯_갈래로_규격까지_이어_섬() -> None: + """[주]① 공기압축기 3.5㎥/분 · 소형브레이커 1㎥/분 · 굴착기 0.7㎥ — 범위 별칭이 코드로 이음 + (카탈로그 0.7 은 무한궤도뿐). 「·」 는 그 갈래에 없음(칸 자리만 지킴).""" + titles = cached_build().book.titles + cut = sorted(code for code in titles if code.startswith(CUT)) + assert len(cut) == 6, cut + sand = _per_m2(CUT + "모래ㆍ사질토ㆍ점토ㆍ점질토") + assert sand == {"1002": 0.005, "X-0201-0070": 0.015}, sand + soft_rock = _per_m2(CUT + "연암") + # 공기압축기 3.5 — 운전경비 표 괄호 딸림값 파서 고침(같은 날)으로 기계 층이 서서 붙음 + assert soft_rock == {"1002": 0.046, "X-5205-0035": 0.125, "X-5210-0010": 0.245}, soft_rock + from B09_Estimation.B09_Estimation_ResourceAxis import ( + build_resource_axis, + load_combined_catalog, + ) + + axis = build_resource_axis(load_work_item_master(), load_combined_catalog()) + rock = { + (r.resource_code, float(r.amount)) + for r in axis.rows + if r.work_item_code == "FP-09-19-01" and r.variant == "절토면 · 연암" + } + assert rock == {("1002", 0.046), ("5205-0035", 0.125), ("5210-0010", 0.245)}, rock + + +def test_토사면_고르기_성토면_인력_두_갈래_기계는_형식_두_갈래() -> None: + """Ⓒ 둘째 줄이 병합 칸 탓에 한 칸 밀려 옴 — 사람 판정표로만 읽음(일반 보정 금지 · 목재틀흙막이 + 503만원 전례). 굴착기 0.6㎥ 는 원문이 형식을 안 적음 — 처음(Ⓑ)엔 근거가 없어 안 세웠고, + 2026-09-14 브레인 ② 로 무한궤도·타이어 두 갈래를 세움(고르기는 B08 칸 · 제안 무한궤도).""" + build = cached_build() + assert _per_m2(FILL + "인력·점토또는점질토") == {"1002": 0.019} + assert _per_m2(FILL + "인력·모래또는사질토") == {"1002": 0.017} + assert _per_m2(FILL + "기계·무한궤도") == {"X-0201-0060": 0.009} + assert _per_m2(FILL + "기계·타이어") == {"X-0211-0060": 0.009} + left = build.unattached.get("FP-09-19-01", []) + assert not any("굴착기" in label for label in left), left + + +def test_범위_별칭은_칸이_규격을_적었으면_덮지_않음() -> None: + """Ⓐ 별칭은 빈 곳을 채우는 것 — 같은 공종 성토면 「굴착기 0.6㎥」 가 0.7 로 덮이면 안 됨.""" + from B09_Estimation.B09_Estimation_ResourceAxis import load_combined_catalog + from B09_Estimation.B09_Estimation_ResourceAxis_Join import ( + apply_scoped_alias, + scoped_alias_entry, + ) + + catalog = load_combined_catalog() + assert scoped_alias_entry(catalog, "굴착기", "FP-09-19-01").code == "0201-0070" + side = ["0.6㎥", "시간", "0.09"] + assert scoped_alias_entry(catalog, "굴착기", "FP-09-19-01", side) is None + # 이름만 갈아도 옆 칸 0.6 으로 무한궤도가 조용히 골라짐 — 이름도 그대로 + assert apply_scoped_alias(catalog, "굴착기", "FP-09-19-01", side) == "굴착기" + # 값·식 칸은 규격이 아님 — 9-19-3 소형브레이커 줄(값 0.275 · 식)은 그대로 이음 + value = ["hr", "0.275", "(2.45+3.05)/2/10㎡"] + assert scoped_alias_entry(catalog, "소형브레이커", "FP-09-19-03", value).code == "5210-0010" + + +def test_토사면_고르기_원문_주는_값을_안_짓고_비고로() -> None: + """Ⓓ [주]③ 착암공은 수량이 없어 줄을 안 지음 · Ⓔ [주]② 풍화암 연암 준용은 선택이라 갈래 아님.""" + note = known_gap_note("FP-09-19-01") + assert "착암공" in note and "준용" in note, note + + +def test_제근은_빌린_밑수가_확정이라_갈래가_서면_내역_금액이_섬() -> None: + """9-21 원문엔 밑수가 없어 마스터 `basis_missing` 에 F0294 가 남음(원문 사실 그대로) — 그러나 + 사용자 확정(건설품셈 3-9-2 「1,000㎡당」 · ÷1,000 은 ChooseOne 이 검)이 있어 내역에서 + 「밑수 미확보」로 막히면 안 됨(2026-09-14 브레인). 교차 참조 사유는 비고에 그대로 남음.""" + from B09_Estimation.B09_Estimation_BasisSheet import open_gaps + from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill + from B09_Estimation.B09_Estimation_UnitPrice import detail_of, load_basis_missing + + assert "F0294" in load_basis_missing() + build = cached_build() + assert "FP-09-21" not in build.basis_missing + assert not [g for g in open_gaps(build) if g["code"] == "FP-09-21"] + title = "B-FP-09-21#굴착기(무한궤도)0.7·소" + # ㎡당 환산이 걸린 값 — 1,000㎡당 그대로면 5만원대로 섬 + assert build.book.title(title) and 50 <= int(detail_of(build, title)["total"]) <= 60 + item = {"work_item_code": "FP-09-21", "name": "지장목제거", "spec": "뿌리뽑기", "unit": "㎡", + "quantity": "1000", "in_bill": True, "variant_axis": "stand_volume_class", + "variant_value": "굴착기(무한궤도)0.7·소"} # fmt: skip + result = build_bill({"work_items": [item], "materials": []}, build=build) + assert not result.missing, result.missing + row = next(r for r in result.rows if r.code == "FP-09-21") + assert row.amount_krw and int(row.amount_krw) > 0, row.note diff --git a/resources/tester/test_b09_verify_guards.py b/resources/tester/test_b09_verify_guards.py index 941371ef..b82f081a 100644 --- a/resources/tester/test_b09_verify_guards.py +++ b/resources/tester/test_b09_verify_guards.py @@ -53,10 +53,14 @@ def test_v16_불도저_운반이_갈래마다_금액으로_선다() -> None: assert build.book.resolve(code).total > 0, code -def test_v16_덤프_운반은_아직_안_선다() -> None: - """⚠ 안 서는 것도 사실대로 못 박는다 — 운반거리(설계 입력)를 기다리는 자리다.""" +def test_v16_덤프_운반은_거리가_와야_선다() -> None: + """⚠ 안 서는 것도 사실대로 못 박는다 — 운반은 운반거리(설계 입력)가 와야 선다. + + 2026-09-14 — 적재(10-12 「1. 적재」)는 거리 무관이라 늘 서고, 운반(`#L…m`)은 거리가 온 조립에만. + """ build = cached_build() - assert not [code for code in build.book.titles if code.startswith("B-FP-10-12")] + dump = [code for code in build.book.titles if code.startswith("B-FP-10-12")] + assert dump and all(code.endswith("#적재") for code in dump) # ── V-17 ──────────────────────────────────────────────────────────── diff --git a/resources/tester/test_b09_waste_disposal_cost.py b/resources/tester/test_b09_waste_disposal_cost.py new file mode 100644 index 00000000..c9ce66ab --- /dev/null +++ b/resources/tester/test_b09_waste_disposal_cost.py @@ -0,0 +1,99 @@ +"""폐기물처리비 자리 — 경비 비목 · 일반관리비·이윤 밑수에 듦 (2026-09-14 판정). + +⚠ 겨누는 것은 **구조**다 — 내역 금액을 박지 않는다(다른 창 절사로 몇 원 움직여도 안 깨지게). + ① 분리발주 아님(기본): 경비 한 줄 → 순공사원가·일반관리비 밑수·이윤 밑수가 **그만큼** 는다 + ② 법정경비 밑수에는 안 섞인다 — 법정경비 줄은 그대로 + ③ 분리발주: 총원가 밖 · 총공사비에만 더한다 +근거 — 예정가격작성기준 제19조③18호(경비 세비목 「폐기물처리비」). +""" + +from __future__ import annotations + +import sys +from decimal import Decimal +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B09_Estimation.B09_Estimation_Engine_Cost import CostInput, calculate_cost # noqa: E402 + +WASTE = Decimal(1_000_000) +#: 규모 구간을 못 박는다 — 폐기물 몫으로 구간이 바뀌면 율이 달라져 구조를 못 잰다. +BASE = dict( + direct_material_krw=Decimal(30_000_000), + direct_labor_krw=Decimal(40_000_000), + direct_expense_krw=Decimal(10_000_000), + estimated_price_krw=Decimal(100_000_000), +) +#: 폐기물이 들면 움직여야 하는 줄 — 나머지(법정경비 등)는 한 푼도 안 움직여야 함. +MOVES = { + "waste_disposal", + "expense", + "net_construction_cost", + "general_overhead", + "profit_before_adjustment", + "profit", + "total_cost", + "vat", + "contract_amount", + "grand_total", +} + + +def _run(waste: Decimal, separate: bool = False): + # ⚠ 절사는 꺼서 본다 — 총공사비가 바뀌면 자동보정액도 따라 바뀌어 **구조**를 못 잰다 + # (2026-09-14 절사 기본 켜짐, 산림청고시 제2025-82호). + return calculate_cost( + CostInput( + **BASE, + waste_disposal_krw=waste, + waste_separate_order=separate, + cut_basis="none", + ) + ) + + +def test_기본은_경비로_들어_일반관리비_이윤_밑수가_는다() -> None: + before, after = _run(Decimal(0)), _run(WASTE) + assert after.line("waste_disposal").amount_krw == WASTE + assert after.amount("expense") - before.amount("expense") == WASTE + assert after.amount("net_construction_cost") - before.amount("net_construction_cost") == WASTE + over = ( + after.line("general_overhead").base_amount_krw + - before.line("general_overhead").base_amount_krw + ) + assert over == WASTE + overhead_gain = after.amount("general_overhead") - before.amount("general_overhead") + assert overhead_gain > 0 + profit_base_gain = ( + after.line("profit_before_adjustment").base_amount_krw + - before.line("profit_before_adjustment").base_amount_krw + ) + assert profit_base_gain == WASTE + overhead_gain + assert "폐기물처리비" in after.line("expense").base_label + + +def test_법정경비_줄은_안_움직인다() -> None: + before, after = _run(Decimal(0)), _run(WASTE) + for line in before.lines: + if line.key not in MOVES: + assert after.amount(line.key) == line.amount_krw, line.key + + +def test_경비_줄보다_앞에_선다() -> None: + keys = [line.key for line in _run(WASTE).lines] + assert keys.index("waste_disposal") < keys.index("expense") < keys.index("general_overhead") + + +def test_분리발주면_총원가_밖_총공사비에만() -> None: + none, separate = _run(Decimal(0)), _run(WASTE, separate=True) + assert separate.amount("total_cost") == none.amount("total_cost") + assert separate.amount("grand_total") - none.amount("grand_total") == WASTE + keys = [line.key for line in separate.lines] + assert keys.index("waste_disposal") > keys.index("contract_amount") + assert separate.totals["waste_disposal"] == WASTE + + +def test_금액이_없으면_줄이_안_선다() -> None: + assert not _run(Decimal(0)).has("waste_disposal") diff --git a/resources/tester/test_user_values_survive.py b/resources/tester/test_user_values_survive.py index 970b0c52..9a8392fe 100644 --- a/resources/tester/test_user_values_survive.py +++ b/resources/tester/test_user_values_survive.py @@ -225,8 +225,11 @@ def test_다단_성토부선이_면적_트림에_들어간다(): ) assert "const extendTrimSlope = (" in source, "트림 확장 자리가 사라짐" # 유출측·집수정 계류측 **둘 다** — 한쪽만 넣으면 그쪽 단만 물량에 잡힌다. - assert "extendTrimSlope(drawnFillPoints(extras)" in source, "유출측 다단이 빠짐" - assert "extendTrimSlope(drawnFillPoints(basinExtras)" in source, "집수정 계류측 다단이 빠짐" + # 면적용은 확정 단까지만(`extrasForArea` — 2026-09-14 ㉠ 미확정 단은 성토를 안 깎음). + assert "extendTrimSlope(drawnFillPoints(extrasForArea)" in source, "유출측 다단이 빠짐" + assert "extendTrimSlope(drawnFillPoints(basinExtrasForArea)" in source, ( + "집수정 계류측 다단이 빠짐" + ) # 그리지 않는 `cut` 갈래(벽이 묻힌 자리)까지 넣으면 면적이 거꾸로 부푼다. assert 'segment.kind !== "cut"' in source, "묻힌 구간을 안 걸러냄" diff --git a/resources/tester/test_work_item_master_variants.py b/resources/tester/test_work_item_master_variants.py index 3e0983c8..0fb13ccc 100644 --- a/resources/tester/test_work_item_master_variants.py +++ b/resources/tester/test_work_item_master_variants.py @@ -18,11 +18,19 @@ def _nodes() -> dict[str, dict]: def test_단가표_갈래_제목은_전부_마스터_갈래_키에_있다() -> None: + """갈래를 **어디서 왔나**로 가름(2026-09-14 브레인 판정) — 표에서 온 갈래만 마스터에 있어야 함. + + 식이 낸 갈래(덤프 운반·적재 10-12)는 마스터가 낼 수 없어 검사 대상이 아니되 + `formula_variants` 에 「식이 냈다」가 적혀 있어야 함(아래 짝 시험). + """ nodes = _nodes() missing = [] - for code in cached_build().book.titles: + build = cached_build(dump_haul_m=("164.23",)) + for code in build.book.titles: if not code.startswith("B-") or "#" not in code: continue + if code in build.formula_variants: + continue work_item, variant = code[2:].split("#", 1) keys = { normalize_variant_key(k) for k in nodes.get(work_item, {}).get("variant_keys") or [] @@ -32,6 +40,18 @@ def test_단가표_갈래_제목은_전부_마스터_갈래_키에_있다() -> N assert not missing, f"마스터를 다시 뽑을 것(B08_Quantity_Build_WorkItemMaster): {missing[:5]}" +def test_식이_낸_갈래는_출처가_적히고_마스터엔_없다() -> None: + build = cached_build(dump_haul_m=("164.23",)) + formula = build.formula_variants + assert "B-FP-10-12-01#적재" in formula and "B-FP-10-12-02#L164.23m" in formula + assert all(code in build.book.titles and why for code, why in formula.items()) + nodes = _nodes() + for code in formula: + work_item, variant = code[2:].split("#", 1) + keys = {normalize_variant_key(k) for k in nodes[work_item].get("variant_keys") or []} + assert variant not in keys # 마스터에 있으면 「표에서 옴」이라 여기 적을 까닭이 없음 + + def test_표_모양대로_갈래를_가르고_못_가르면_빈칸() -> None: nodes = _nodes() felling = nodes["FP-04-02-02"]["tables"][0]["variant_key"] diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index 0dcfca77..df2909ad 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -17,10 +17,13 @@ * 이 파일의 export 시그니처는 분할 이전과 동일하므로 소비처 import 수정은 불필요하다. * ========================================================================== */ +// b4 = B09 원가계산서·제비율(랩탑 메인 벌). 다른 벌과 줄이 겹치지 않게 맨 앞에 둠. +import { ui_locales_b4 } from "./ui_template_locale_b4"; import { ui_locales_common } from "./ui_template_locale_common"; import { ui_locales_a } from "./ui_template_locale_a"; import { ui_locales_b1 } from "./ui_template_locale_b1"; import { ui_locales_b2 } from "./ui_template_locale_b2"; +import { ui_locales_b3 } from "./ui_template_locale_b3"; /** 지원 언어 인덱스: 0 = 한국어, 1 = 영어 */ export const LANGUAGES = ["ko", "en"] as const; @@ -49,10 +52,12 @@ export function t(key: keyof typeof ui_locales): string { /** 분할된 사전 4종을 합성한 단일 사전. 키는 파일 간 중복되지 않는다. */ export const ui_locales = { + ...ui_locales_b4, ...ui_locales_common, ...ui_locales_a, ...ui_locales_b1, ...ui_locales_b2, + ...ui_locales_b3, } as const; export type LocaleKey = keyof typeof ui_locales; diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 14cbc9b1..69cd4fb0 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -672,8 +672,8 @@ export const ui_locales_b2 = { "Area × this = ㎥ — blank keeps the bench-cut row blocked", ], B08_Quantity_Side_BenchCut_Suggest: [ - "제안값(확정 아님) — 토사 깊이 0.5 m 이상 · 암 깊이 0.4 m 이상 · 폭 1.0 m 이상. 출처: 산림과임업기술 「임도 시공」 편절토·편성토 접속부 그림의 층따기 치수표(기술서적 · 교본은 「설계도서에 명시」). ⚠ 원본 그림을 확인하기 전이라 칸에 자동으로 넣지 않습니다", - "Suggestion, not confirmed — soil depth ≥ 0.5 m · rock ≥ 0.4 m · width ≥ 1.0 m (technical book table; the manual defers to design documents). Not auto-filled until the original figure is checked", + "층따기 표준치수 — 최소 높이 토사 500 mm 이상 · 암반 400 mm 이상 · 폭 1 m 이상(기계 작업을 할 때는 3 m 이상). 근거: KDS 44 30 00 도로토공 2.3.4 (4) ② 「기초지반의 종류에 따른 층따기의 표준치수」", + "Bench cut standard dimensions — minimum height: soil ≥ 500 mm · rock ≥ 400 mm · width ≥ 1 m (≥ 3 m for machine work). Basis: KDS 44 30 00 Road Earthwork 2.3.4 (4) ②", ], B08_Quantity_Side_Rubble_Label: ["기초잡석 두께(m)", "Rubble base thickness (m)"], B08_Quantity_Side_Rubble_Hint: [ @@ -711,6 +711,52 @@ export const ui_locales_b2 = { "기본은 안 셉니다(확정 5차 5번) — 현장에 따라 필요하면 켜세요. ⚠ 켜도 부피를 넣어야 값이 섭니다(실무는 부피로 셈)", "Off by default — turn on per site. Volume must be entered for the row to carry a quantity", ], + B08_Quantity_Side_TreeWaste: ["임목폐기물", "Tree Waste"], + B08_Quantity_TreeWaste_Stems: ["표본지 1,000㎡당 본수", "Stems per 1,000 ㎡ (sample plot)"], + B08_Quantity_TreeWaste_Dbh: ["평균 흉고직경(㎝)", "Mean DBH (cm)"], + B08_Quantity_TreeWaste_Height: ["평균 수고(m)", "Mean tree height (m)"], + B08_Quantity_TreeWaste_UnitWeight: ["단위체적중량(㎏/㎥)", "Unit weight (kg/㎥)"], + B08_Quantity_Side_TreeWaste_Hint: [ + "현장 조사값 넷을 넣으면 준비공 「임목폐기물 처리」가 톤으로 섭니다. 산식: 지상부 WA = 0.5 × 3.14 × (흉고직경/2)² × 수고 × (1+0.3) × 단위체적중량 × 본수 · 본수 = 1,000㎡당 본수 × 벌목·지장목제거 면적 — 환경친화적인 도로건설 지침 부록1 · 한국건설기술연구원(2012) 3.2. 단위체적중량 참고: 소나무·침엽수 1,210~1,250 · 단풍·산벚 1,250~1,300 · 느티 1,300~1,340 · 상수리·졸참 1,340 이상(조경설계요람). 기본값 없음 — 수종·임분마다 다름", + "Enter the four field-survey values to raise the tree-waste row in tonnes. WA = 0.5 × 3.14 × (DBH/2)² × height × 1.3 × unit weight × stems (road guideline appendix 1, KICT 2012). No defaults — values differ by species and stand", + ], + B08_Quantity_TreeWaste_RootMethod: ["뿌리부 산정법", "Root estimate"], + B08_Quantity_TreeWaste_RootRatio: [ + "분배비 WA × 15/85 (기본)", + "Share ratio WA × 15/85 (default)", + ], + B08_Quantity_TreeWaste_RootBall: ["뿌리분 체적 × 1,300㎏/㎥", "Root-ball volume × 1,300 kg/㎥"], + B08_Quantity_TreeWaste_RootBallDiameter: [ + "뿌리분 직경 D(m, 흉고직경 아님) — 체적법만", + "Root-ball diameter D (m, not DBH) — volume method", + ], + B08_Quantity_TreeWaste_Root_Hint: [ + "환경친화적인 도로건설 지침 부록1 이 두 방법을 둡니다. 분배비(기본): 뿌리 15 : 지상 85(일반 수목류, 한국건설기술연구원 2012). 뿌리분 체적: V = πD²(D/2 + RH2/3) = 0.3927D³ · 뿌리분 중량 = V × 1,300㎏/㎥ × 본수. ⚠ 지침은 D 를 「뿌리분의 직경(DBH, m)」으로 적어 흉고직경인지 뿌리분 직경인지 갈리지 않음 — 흉고직경으로 두지 않고 따로 받습니다. 참고: 실무 실정보고(한 현장)는 뿌리 14 : 86 · 0.65톤/㎥ 를 씀", + "Road guideline appendix 1 gives two methods: share ratio 15:85 (default), or root-ball volume 0.3927·D³ × 1,300 kg/㎥ × stems. The guideline labels D ambiguously, so it is entered separately", + ], + B08_Quantity_TreeWaste_Price: ["처리단가(원/톤) — 수동", "Disposal price (KRW/t) — manual"], + B08_Quantity_TreeWaste_Price_Hint: [ + "⚠ 공표 단가 없음(한국건설자원협회 중간처리단가 미확보) — 넣으면 금액이 서되 「미확정」으로 셉니다(빨간 테두리). 비목은 경비 — 원가계산서 폐기물처리비 줄로 가고 일반관리비·이윤 밑수에 듭니다(예정가격작성기준 제19조③18호)", + "No published price — a manual price raises the amount as unconfirmed (red outline). Cost category is expense, inside the overhead and profit bases", + ], + B08_Quantity_TreeWaste_Separate: ["분리발주", "Separate disposal contract"], + B08_Quantity_TreeWaste_Separate_Off: ["분리발주 아님(기본)", "No (default)"], + B08_Quantity_TreeWaste_Separate_On: ["분리발주", "Yes"], + B08_Quantity_TreeWaste_Separate_Hint: [ + "⚠ 임목폐기물은 분리발주 대상 아님(환경부 질의회신 — 임목폐기물처리 실정보고 p4 · 건설폐기물 업무처리지침: 건설폐기물 아닌 사업장 일반폐기물) — 기본 꺼짐으로 원가계산서 경비에 듭니다. 이 칸은 다른 건설폐기물(위탁처리 100톤 이상 분리발주 대상)이 생길 때 쓰는 칸 — 켜면 총원가 밖에 서고 총공사비에만 더합니다. 5톤 이상은 배출자 신고 대상(폐기물관리법 시행령 제2조8호)", + "Tree waste is not subject to separate ordering (Ministry of Environment reply; classified as business general waste) — off by default, inside the expense category. Use this switch for other construction waste (100 t or more). When on, it sits outside total cost. 5 t or more requires a discharge report", + ], + B08_Quantity_Side_FaceDressing: ["면고르기", "Face dressing"], + B08_Quantity_FaceDressing_Cut: ["절토면 토질", "Cut face soil"], + B08_Quantity_FaceDressing_Fill: ["성토면 시공·토질", "Fill face method·soil"], + B08_Quantity_FaceDressing_Unset: ["안 정함(금액 안 섬)", "Not set (no amount)"], + B08_Quantity_FaceDressing_FillArea: ["성토면 면적(㎡)", "Fill face area (㎡)"], + B08_Quantity_FaceDressing_CutArea: ["절토면 면적(㎡)", "Cut face area (㎡)"], + B08_Quantity_FaceDressing_Area_Placeholder: ["파종 면적 그대로", "Same as seeding area"], + B08_Quantity_Side_FaceDressing_Hint: [ + "품셈 9-19-1 원문 표 두 벌(절토면 토질 6 · 성토면 시공·토질) — 제안값 없음, 안 고르면 내역 줄이 입력 사유로 섭니다. 면적은 비우면 초류종자살포 면적(× 반영률) 그대로 · 넣으면 그 값으로 덮어씀", + "Standard estimate 9-19-1 tables (cut face soil · fill face method/soil) — no suggested value; unset leaves the bill row with an input reason. Area left blank uses the seeding area (× ratio); a value overrides it", + ], B08_Quantity_Side_SubgradeCompaction: ["노체다짐", "Subgrade Compaction"], B08_Quantity_SubgradeCompaction_Label: ["노체다짐을 셀 것인가", "Count subgrade compaction"], B08_Quantity_Side_SubgradeCompaction_Hint: [ diff --git a/ui_template/ui_template_locale_b3.ts b/ui_template/ui_template_locale_b3.ts new file mode 100644 index 00000000..b79d3809 --- /dev/null +++ b/ui_template/ui_template_locale_b3.ts @@ -0,0 +1,110 @@ +/* ============================================================================= + * ui_template_locale_b3.ts + * B 그룹 사전 3 — B09 실무 서식 화면(설계내역서·일위대가표·단가산출근거, PLAN 12장) + * + * b2 가 700줄을 넘어 새 벌로 뗌. 신규 문구는 이 파일 최하단에 추가. + * ========================================================================== */ + +export const ui_locales_b3 = { + /* --- B09 실무 서식 표 --- */ + B09_Sheet_Col_ItemNo: ["공종", "Item"], + B09_Sheet_Col_Name: ["명칭", "Name"], + B09_Sheet_Col_Spec: ["규격", "Spec"], + B09_Sheet_Col_Quantity: ["수량", "Qty"], + B09_Sheet_Col_Unit: ["단위", "Unit"], + B09_Sheet_Col_Total: ["합계", "Total"], + B09_Sheet_Col_Labor: ["노무비", "Labor"], + B09_Sheet_Col_Material: ["재료비", "Material"], + B09_Sheet_Col_Expense: ["경비", "Expense"], + B09_Sheet_Col_UnitPrice: ["단가", "Unit price"], + B09_Sheet_Col_Amount: ["금액", "Amount"], + B09_Sheet_Col_Note: ["비고", "Note"], + B09_Sheet_Col_Sheet: ["호표", "Sheet"], + B09_Sheet_Col_Basis: ["산근", "Basis"], + B09_Sheet_Sum: ["합계", "Total"], + B09_Sheet_BodyTotal: ["내역 본체 합계", "Bill body total"], + B09_Sheet_Won: ["원", " KRW"], + B09_Sheet_Count: ["건", ""], + B09_Sheet_Unconfirmed: ["미확정", "Unconfirmed"], + B09_Sheet_Unpriced: ["금액에 안 들어감", "not in the amount"], + B09_Sheet_Missing: ["금액을 못 세운 줄", "Rows without a price"], + B09_Sheet_Reload: ["다시 불러오기", "Reload"], + B09_Sheet_Level: ["보이는 레벨", "Show levels"], + B09_Sheet_Level_All: ["모두", "All"], + B09_Sheet_Level_Upto: ["레벨까지", " level(s)"], + B09_Sheet_Loading: ["불러오는 중…", "Loading…"], + B09_Sheet_LoadFailed: ["불러오지 못했습니다.", "Could not load."], + B09_Sheet_NoProject: ["프로젝트를 먼저 고르세요.", "Pick a project first."], + B09_Sheet_Open_UnitPrice: [ + "누르면 이 줄의 일위대가표로 들어갑니다", + "Click to open this row's unit price sheet", + ], + B09_Sheet_Drill: ["누르면 이 줄의 호표로 들어갑니다", "Click to open this row's sheet"], + B09_Sheet_Basis_Short: ["단산", "Basis"], + B09_Sheet_Basis_Long: ["산근", "Basis"], + B09_Sheet_Basis_Suffix: ["호표", ""], + B09_Sheet_Machine: ["시간당 중기", "Machine hourly"], + B09_Sheet_UnitPriceList: ["일위대가 목록표", "Unit price list"], + B09_Sheet_BasisList: ["단가산출근거 목록표", "Price basis list"], + B09_Sheet_EmptyList: [ + "내역에 쓰인 호표가 없습니다 — 설계내역서가 먼저 서야 합니다.", + "No sheets used by the bill yet.", + ], + B09_Sheet_PickSheet: [ + "목록표에서 호표를 누르면 본표가 아래에 섭니다.", + "Click a sheet above to see its table.", + ], + B09_Sheet_StructureSheet: [ + "구조물도 일위대가는 수량산출 › 구조물도 탭의 일위대가 표가 본표입니다.", + "Structure unit prices are shown on the structure drawing tab.", + ], + B09_Sheet_MachineList: ["중기 목록표", "Machine list"], + B09_Sheet_List_Material: ["재료비 목록표", "Material list"], + B09_Sheet_List_Labor: ["노무비 목록표", "Labor list"], + B09_Sheet_List_Expense: ["경비 목록표", "Expense list"], + B09_Sheet_List_Lumpsum: ["일식견적 목록표", "Lump-sum list"], + B09_Sheet_Sum_Material: ["재료비 수량금액집계표", "Material summary"], + B09_Sheet_Sum_Labor: ["노무비 수량금액집계표", "Labor summary"], + B09_Sheet_Sum_Expense: ["경비 수량금액집계표", "Expense summary"], + B09_Sheet_Sum_Machine: ["중기 시간금액집계표", "Machine hours summary"], + B09_Sheet_SumMissing: ["집계에 못 넣은 코드:", "Codes left out of the summary:"], + B09_Sheet_EmptyGroup: ["이 표에 실릴 줄이 없습니다.", "No rows for this table."], + B09_Sheet_Tab_Summary: ["집계표", "Summaries"], + B09_Sheet_Tab_Lists: ["목록표", "Lists"], + B09_Sheet_Tab_PriceCompare: ["자재단가대비표", "Material price comparison"], + B09_Sheet_Col_Page: ["페이지", "Page"], + B09_Sheet_Min: ["최소", "min"], + B09_Sheet_User: ["사용자", "User"], + B09_Sheet_Undo: [ + "고친 값을 지우고 계산값으로 되돌림", + "Clear the edit and return to the computed value", + ], + B09_Sheet_Adopt: ["채택", "Adopt"], + B09_Sheet_Bulk: ["일괄 채택", "Adopt for all"], + B09_Sheet_Bulk_None: ["변동없음", "No change"], + B09_Sheet_Bulk_Min: ["최소단가", "Lowest price"], + B09_Sheet_Apply: ["적용", "Apply"], + B09_Sheet_Saved: ["저장했습니다 — 금액을 다시 셈했습니다.", "Saved — prices recalculated."], + B09_Sheet_SaveFailed: ["저장하지 못했습니다:", "Could not save:"], + B09_Sheet_EditSkipped: ["얹지 못한 고친 값:", "Edits not applied:"], + B09_Sheet_Edit: ["편집", "Edit"], + B09_Sheet_EditDone: ["편집 끝", "Done"], + B09_Sheet_RemoveRow: ["이 줄을 뺌(↺ 로 되돌림)", "Remove this row (undo with ↺)"], + B09_Sheet_AddRow: ["줄 더하기", "Add row"], + B09_Sheet_SearchTitle: [ + "노임·자재·중기·일위대가 이름으로 찾기", + "Search labor, material, machine or unit price", + ], + B09_Sheet_Rate: ["할증·일괄보정", "Surcharge / bulk adjust"], + B09_Sheet_Rate_Selected: ["선택 항목만", "Selected rows"], + B09_Sheet_Rate_All: ["내역서 전체", "Whole bill"], + B09_Sheet_Rate_Same: ["재·노·경 같은 비율", "Same rate"], + B09_Sheet_Rate_Parts: ["비목별", "Per component"], + B09_Sheet_Rate_Floor: ["절사", "Floor"], + B09_Sheet_Rate_Round: ["반올림", "Round"], + B09_Sheet_Rate_Digits: ["소수", "Decimals"], + B09_Sheet_Rate_PickRows: ["할증할 줄을 먼저 고르세요.", "Pick rows first."], + B09_Sheet_Rate_ClearAll: ["할증 전부 되돌리기", "Clear all surcharges"], + B09_Sheet_Rate_SelectedCount: ["고른 줄", "Selected"], + B09_Sheet_Rate_AllBadge: ["전체 할증", "Bill-wide"], +} as const; diff --git a/ui_template/ui_template_locale_b4.ts b/ui_template/ui_template_locale_b4.ts new file mode 100644 index 00000000..2a8405ae --- /dev/null +++ b/ui_template/ui_template_locale_b4.ts @@ -0,0 +1,20 @@ +/* ============================================================================= + * ui_template_locale_b4.ts + * B09 원가계산서·제비율 요율표 탭 사전 (랩탑 메인 벌 · 2026-09-14 브레인 배정) + * + * b2 가 700줄을 넘어 벌을 나눔 — b3 은 랩탑_서브(내역서·일위대가·단가산출근거) 벌. + * 형식: 키 → [한국어, 영어]. + * ========================================================================== */ + +export const ui_locales_b4 = { + B09_Estimation_Tab_RateTable: ["제비율 요율표", "Overhead Rate Table"], + B09_Estimation_Tab_Contract: ["계약내역", "Contract Bill"], + B09_Estimation_Tab_Execution: ["실행예산", "Execution Budget"], + B09_Estimation_Tab_Progress: ["기성", "Progress Payment"], + B09_Estimation_Tab_Completion: ["준공", "Completion"], + B09_Estimation_Tab_MaterialPrices: ["자재 단가", "Material Prices"], + B08_Quantity_Side_BenchCut_Fill: [ + "제안값 넣기 — KDS 표준치수 토사 0.5 m", + "Insert suggested value — KDS standard soil 0.5 m", + ], +} as const;