Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b516ffed66 | ||
|
|
15d91be7b0 | ||
|
|
0fa6cd0898 | ||
|
|
0989471901 | ||
|
|
6ea73956e5 | ||
|
|
8ed4be855b | ||
|
|
20597071bd | ||
|
|
47196780b2 | ||
|
|
fd0ea82975 | ||
|
|
8dddc76839 | ||
|
|
edc8148515 | ||
|
|
8bf24c268f | ||
|
|
7ea2598263 | ||
|
|
142b100a09 | ||
|
|
c51722ae8b | ||
|
|
a842766658 | ||
|
|
eb1a78f9e1 | ||
|
|
05d542add3 | ||
|
|
d57e97a1cd | ||
|
|
ac68232d28 | ||
|
|
efaf2403a0 | ||
|
|
8f4ec48759 | ||
|
|
27e068aa12 | ||
|
|
b6d5c9acc8 | ||
|
|
1e791b487d | ||
|
|
09247fd16f | ||
|
|
3ecc23ac93 | ||
|
|
2799cad714 | ||
|
|
1400b7b761 | ||
|
|
c7f34b8c4b | ||
|
|
027c305e16 | ||
|
|
475ad7875e | ||
|
|
af609a381e | ||
|
|
7c9f75ca12 | ||
|
|
5a016979c4 | ||
|
|
b23db36c78 | ||
|
|
3ca1a45b2b | ||
|
|
72ec6c2135 | ||
|
|
0bb14ce2b3 | ||
|
|
ecd25ae9ee | ||
|
|
1550c7caf6 | ||
|
|
99a79e8e0b | ||
|
|
60d7c005bd | ||
|
|
26d9092a9b | ||
|
|
29a0e64b30 | ||
|
|
64e43463f4 | ||
|
|
254007573f | ||
|
|
64a52df680 | ||
|
|
a744388276 | ||
|
|
70d9329fb6 | ||
|
|
42909a01b9 | ||
|
|
5d2e1edbde | ||
|
|
8dd452d8f4 | ||
|
|
31decd1558 | ||
|
|
9e240d886d | ||
|
|
9022640b5f | ||
|
|
00919e86e2 | ||
|
|
b95ad5e838 | ||
|
|
9d59a32898 | ||
|
|
eff340aad3 | ||
|
|
ef03daa482 | ||
|
|
fcb21eb945 | ||
|
|
9d8c3a6068 | ||
|
|
fc825dc9d3 | ||
|
|
33ff0c50f0 | ||
|
|
3886f3bfc1 | ||
|
|
5c2ae377e0 | ||
|
|
976e5fd992 | ||
|
|
2745a41963 | ||
|
|
91317295f5 | ||
|
|
38a5e785f2 | ||
|
|
d6fd3c1796 | ||
|
|
6f484eb6fa | ||
|
|
2e678ad511 | ||
|
|
6c61f5d5ac | ||
|
|
391301abf4 | ||
|
|
7b5f8bc5e2 | ||
|
|
ee9368ec66 | ||
|
|
b08b79bc6c | ||
|
|
c7b9956bf3 | ||
|
|
eb79091e00 | ||
|
|
df547a90f8 | ||
|
|
f4f9b77bdf | ||
|
|
e9b8eacc90 | ||
|
|
ce8339f34a | ||
|
|
52c7148927 | ||
|
|
ae52a92753 | ||
|
|
d8e6e52f75 | ||
|
|
8dfa68df32 | ||
|
|
d352774ce2 | ||
|
|
42d5566dc9 | ||
|
|
c4a83dbb09 | ||
|
|
c59dbba482 | ||
|
|
5f3c55f32b | ||
|
|
8a17d8308b | ||
|
|
6b7c093e7e | ||
|
|
e26ef204fc | ||
|
|
b80eabf4f1 | ||
|
|
bba23376f1 | ||
|
|
0e609e69a6 | ||
|
|
626f518064 | ||
|
|
8a31f87412 | ||
|
|
e8cce87413 | ||
|
|
e85ea2fa2c | ||
|
|
76b17098e3 | ||
|
|
6b083ce3c5 | ||
|
|
ad63dd8f99 | ||
|
|
db5822f4f5 | ||
|
|
946ac82b5b | ||
|
|
330421fe94 | ||
|
|
b0355d5ae1 | ||
|
|
7e036ddd51 | ||
|
|
2b464138ae | ||
|
|
560df4ac83 | ||
|
|
f9ccc8f6fb | ||
|
|
95ce24d31c | ||
|
|
2604e9e914 | ||
|
|
acb377f5db | ||
|
|
beb30999e3 | ||
|
|
298133dce4 | ||
|
|
ac801cedfe | ||
|
|
d6820db7b0 | ||
|
|
ecf3b65b42 | ||
|
|
290eccbb92 | ||
|
|
bd69aa4788 | ||
|
|
37791c5bfb | ||
|
|
a0e144aed5 | ||
|
|
646081757c | ||
|
|
71831432d1 | ||
|
|
c12a2bf826 | ||
|
|
afea225ae5 | ||
|
|
87083c3341 | ||
|
|
e6e6f9b7d5 | ||
|
|
57a949c624 | ||
|
|
aefb476577 | ||
|
|
fdb7e27bed | ||
|
|
273132f8f6 | ||
|
|
aeb18266bd | ||
|
|
996300752d | ||
|
|
d5c5fe7b40 | ||
|
|
c4eccee51f | ||
|
|
0a7f4a5008 | ||
|
|
fc2ecc0f39 | ||
|
|
0244749d29 | ||
|
|
2744f1bcbb | ||
|
|
48e3eeee08 | ||
|
|
7229c0ab4e | ||
|
|
6a427e78f9 | ||
|
|
eb14ec1c69 | ||
|
|
7eecfacca3 | ||
|
|
4015c27c90 | ||
|
|
8eb1189b4f | ||
|
|
f07ff0033f | ||
|
|
d5bdd2ee17 | ||
|
|
9cd2920afc | ||
|
|
60aac3892d | ||
|
|
a440b4f919 | ||
|
|
a2b3ae698f | ||
|
|
a1b1182b9b | ||
|
|
128106e76d | ||
|
|
0eebb50c03 | ||
|
|
58afe8f2d2 | ||
|
|
999aeac755 | ||
|
|
139e505843 | ||
|
|
e3dbef9700 | ||
|
|
6eb651d054 | ||
|
|
81286400bd | ||
|
|
5bbb0aa53b | ||
|
|
7b2affe119 | ||
|
|
fd324ce531 | ||
|
|
f3630ab1e7 | ||
|
|
bd571f0454 | ||
|
|
27192c739a | ||
|
|
b97cb4e2e6 | ||
|
|
808cf0f289 | ||
|
|
b84ee14bea | ||
|
|
7be245db2b | ||
|
|
e37a425b2e | ||
|
|
81b51de517 | ||
|
|
1e9ff999a4 | ||
|
|
ee3c60f76c | ||
|
|
c4ecf3bd2b | ||
|
|
7b6fe3873b | ||
|
|
deac820044 | ||
|
|
fd344a5311 | ||
|
|
a7a1b246b6 | ||
|
|
7d3b062c3e | ||
|
|
a98689c966 | ||
|
|
a88f9a625b | ||
|
|
951e39a923 | ||
|
|
cdd3a29452 | ||
|
|
efe55e9d34 | ||
|
|
bb8b9405a4 | ||
|
|
632e47a1ba | ||
|
|
6454f4fefb | ||
|
|
2c0ae48ac7 | ||
|
|
ba2c36e803 | ||
|
|
c3060f9bb6 | ||
|
|
0f5d7da1e2 | ||
|
|
aba96f6116 | ||
|
|
2f2cc92d15 | ||
|
|
1edde503fe | ||
|
|
ce5bfb7722 | ||
|
|
650f660ed1 | ||
|
|
2ab3a65807 | ||
|
|
87cab83c60 | ||
|
|
914bbce74c | ||
|
|
f1f8482e7d | ||
|
|
5e983018a3 | ||
|
|
bc1b2fdad0 | ||
|
|
ff6672a13a | ||
|
|
31675aaf21 | ||
|
|
1b48a16a18 | ||
|
|
8646c56185 | ||
|
|
a9ddcd5b1b | ||
|
|
52771faefe | ||
|
|
26097d6922 | ||
|
|
415a76a97b | ||
|
|
6926d7fd14 | ||
|
|
1d21253b7e | ||
|
|
6a2c5aacae | ||
|
|
6683e85d94 | ||
|
|
63e715fd9f | ||
|
|
0112f3fcab | ||
|
|
8046205039 | ||
|
|
b9d0ac0866 | ||
|
|
8693cf0012 | ||
|
|
d4ab146e02 | ||
|
|
c3618fe872 | ||
|
|
b02c51db71 | ||
|
|
35abb49d3c | ||
|
|
f704bc3bf8 | ||
|
|
6cfcafc2f4 | ||
|
|
9cf01ab15c | ||
|
|
267bad1dfd | ||
|
|
72046265fc | ||
|
|
3a250fb055 | ||
|
|
964375d2a7 | ||
|
|
ef48e6edd3 | ||
|
|
fb2de4a679 | ||
|
|
fb4eb59cdb | ||
|
|
71d643b456 | ||
|
|
3327357f2e | ||
|
|
baeaa26702 | ||
|
|
800cb73be0 | ||
|
|
4c6268ffe8 | ||
|
|
9158a88642 | ||
|
|
3ce997fa69 | ||
|
|
155903306e | ||
|
|
a68a5e0214 | ||
|
|
b7af20f76d | ||
|
|
3057a751da | ||
|
|
cb33e81d98 | ||
|
|
243c6eb3a9 | ||
|
|
85f7c9c6b3 | ||
|
|
509660708b | ||
|
|
3ab63eb29c | ||
|
|
314c00406e | ||
|
|
7b0a48f0cd | ||
|
|
35ba45f572 | ||
|
|
bdc08c3251 | ||
|
|
1733bb6aac | ||
|
|
ae9578a6dc | ||
|
|
7d856f37a3 | ||
|
|
54a17580da | ||
|
|
49df2f10ef | ||
|
|
fc649316c6 | ||
|
|
0cf8b41823 | ||
|
|
d6916225de | ||
|
|
af23a5d209 | ||
|
|
9f72ad19b8 | ||
|
|
e5295dc181 | ||
|
|
b876ba9c14 | ||
|
|
82755260e2 | ||
|
|
dfa99ed594 | ||
|
|
c919a982b4 | ||
|
|
7bd1dc1e3a | ||
|
|
50c3fb65f5 | ||
|
|
7d7b9cf8fa | ||
|
|
219c7068a9 | ||
|
|
fd30227b73 | ||
|
|
f296b8c88c | ||
|
|
5e76850d3b | ||
|
|
70885683eb | ||
|
|
61e743451f | ||
|
|
edd9369e3b | ||
|
|
0713dbefb0 | ||
|
|
fb3442709f | ||
|
|
165bfed0a8 | ||
|
|
c34ab96763 | ||
|
|
d56fa18971 | ||
|
|
4ca757fadc | ||
|
|
7bf5cbd1d1 | ||
|
|
679f54853c | ||
|
|
66d57bb630 | ||
|
|
e88877d81e | ||
|
|
41e0b5b7ea | ||
|
|
846c4bba57 | ||
|
|
d1958d067b | ||
|
|
c2e3c4cda2 | ||
|
|
6634e21713 | ||
|
|
938eef94bd | ||
|
|
44b20ed21f | ||
|
|
fd4f00ab7e | ||
|
|
ec194ee05b | ||
|
|
cacbb08f10 | ||
|
|
46b582762c | ||
|
|
644640ee59 | ||
|
|
e3c1119f5d | ||
|
|
1c79fc288c | ||
|
|
d35c5082d9 | ||
|
|
d9c84aaaf0 | ||
|
|
be1dfcd3a1 | ||
|
|
980c5440a0 | ||
|
|
9a758c4d27 | ||
|
|
d72f6ed13a | ||
|
|
aab43d652c |
@@ -52,7 +52,7 @@ const routeTable: Partial<Record<RoutePath, () => Promise<PageRenderer>>> = {
|
||||
[ROUTES.B08_QUANTITY]: async () =>
|
||||
(await import("../B08_Quantity/B08_Quantity_UI_Page")).renderB08Quantity,
|
||||
[ROUTES.B09_ESTIMATION]: async () =>
|
||||
(await import("../B09_Estimation/B09_Estimation_UI_Page")).renderB09Estimation,
|
||||
(await import("../B09_Estimation/B09_Estimation_UI_Shell")).renderB09Estimation,
|
||||
[ROUTES.B10_PAYMENT]: async () =>
|
||||
(await import("../B10_Payment/B10_Payment_UI_Page")).renderB10Payment,
|
||||
[ROUTES.B11_STATUS]: async () =>
|
||||
|
||||
@@ -35,6 +35,13 @@ 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;
|
||||
/** 기본값의 뜻(도메인 확정값이 아닐 때) — 칸 밑 근거 한 줄. */
|
||||
default_basis?: string | null;
|
||||
}
|
||||
|
||||
/** 구조물 배치 폼을 어느 화면이 쓰는가 — B05 는 유무·종류·위치만, **B06/B07 은 상세
|
||||
|
||||
@@ -134,9 +134,10 @@
|
||||
"label": "유입 기슭막이 높이",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 2.5,
|
||||
"default": null,
|
||||
"required": false,
|
||||
"phase": "detail"
|
||||
"phase": "detail",
|
||||
"empty_means": "비우면 관경 기준 최소 높이(관경 + 여유 0.5 를 0.1m 로 올림 + 근입 0.5)로 그리고 셈 — 횡단도·구조물도·표가 같은 값 · 최소라 수량은 미확정(금액 밖) · 2026-09-14 브레인 판정(옛 기본 2.5 걷음)"
|
||||
},
|
||||
{
|
||||
"key": "outlet_type",
|
||||
@@ -186,9 +187,10 @@
|
||||
"label": "유출 기슭막이 높이",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 2.5,
|
||||
"default": null,
|
||||
"required": false,
|
||||
"phase": "detail"
|
||||
"phase": "detail",
|
||||
"empty_means": "비우면 관경 기준 최소 높이(관경 + 여유 0.5 를 0.1m 로 올림 + 근입 0.5)로 그리고 셈 — 횡단도·구조물도·표가 같은 값 · 최소라 수량은 미확정(금액 밖) · 2026-09-14 브레인 판정(옛 기본 2.5 걷음)"
|
||||
},
|
||||
{
|
||||
"key": "revet_foundation",
|
||||
@@ -332,6 +334,26 @@
|
||||
"unit": "%",
|
||||
"default": null,
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"key": "thickness_cm",
|
||||
"label": "포장 두께",
|
||||
"input": "number",
|
||||
"unit": "㎝",
|
||||
"default": null,
|
||||
"required": false,
|
||||
"phase": "detail",
|
||||
"empty_means": "비우면 표가 안 서고 「두께를 적어야 섬」 사유가 뜸 — 두께로 관측 원단위(울진 20㎝)를 고름(2026-09-14 A3)"
|
||||
},
|
||||
{
|
||||
"key": "length_m",
|
||||
"label": "포장 길이(노폭 방향)",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": null,
|
||||
"required": false,
|
||||
"phase": "detail",
|
||||
"empty_means": "비우면 면적(월류 폭 × 길이)이 0 이라 표가 안 서고 사유가 뜸 — 노견까지 전폭을 적음(2026-09-14 A3)"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -479,6 +501,15 @@
|
||||
"required": false,
|
||||
"phase": "detail",
|
||||
"empty_means": "비우면 실무 붙박이(콘크리트 개거)로 돎"
|
||||
},
|
||||
{
|
||||
"key": "length_m",
|
||||
"label": "연장",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": null,
|
||||
"required": false,
|
||||
"empty_means": "비우면 표가 안 서고 「연장을 적어야 섬」 사유가 뜸 — 개거는 m당 원단위라 연장이 곧 밑수(2026-09-14 A2)"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -695,9 +726,11 @@
|
||||
"label": "높이",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 2.5,
|
||||
"default": 2.0,
|
||||
"default_basis": "기본값 · 소광리 도면 H=2.0 · 바꿀 수 있음",
|
||||
"required": false,
|
||||
"phase": "b05"
|
||||
"phase": "b05",
|
||||
"empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)"
|
||||
},
|
||||
{
|
||||
"key": "length_m",
|
||||
@@ -783,7 +816,8 @@
|
||||
"unit": "m",
|
||||
"default": 2.5,
|
||||
"required": false,
|
||||
"phase": "b05"
|
||||
"phase": "b05",
|
||||
"empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)"
|
||||
},
|
||||
{
|
||||
"key": "length_m",
|
||||
@@ -888,6 +922,16 @@
|
||||
"phase": "detail",
|
||||
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김"
|
||||
},
|
||||
{
|
||||
"key": "face_role",
|
||||
"label": "성토/절토",
|
||||
"input": "select",
|
||||
"choices": ["성토", "절토"],
|
||||
"default": null,
|
||||
"required": false,
|
||||
"phase": "detail",
|
||||
"empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김"
|
||||
},
|
||||
{
|
||||
"key": "foundation",
|
||||
"label": "기초",
|
||||
@@ -956,7 +1000,10 @@
|
||||
"unit": "m",
|
||||
"default": 2.5,
|
||||
"required": false,
|
||||
"phase": "b05"
|
||||
"phase": "b05",
|
||||
"empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)",
|
||||
"warn_above": 2.0,
|
||||
"warn_message": "메쌓기 높이 기준을 넘음 — 임도기술교본 7-3:27 「메쌓기는 2.0m 이하」 · 품셈 13-4-4 [주]⑪(원문 L7192) 「높이 3m 이상이면 전부 또는 하부를 찰쌓기」 · 막지 않음(설계자 확인)"
|
||||
},
|
||||
{
|
||||
"key": "length_m",
|
||||
@@ -1061,6 +1108,16 @@
|
||||
"phase": "detail",
|
||||
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김"
|
||||
},
|
||||
{
|
||||
"key": "face_role",
|
||||
"label": "성토/절토",
|
||||
"input": "select",
|
||||
"choices": ["성토", "절토"],
|
||||
"default": null,
|
||||
"required": false,
|
||||
"phase": "detail",
|
||||
"empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김"
|
||||
},
|
||||
{
|
||||
"key": "foundation",
|
||||
"label": "기초",
|
||||
@@ -1222,7 +1279,8 @@
|
||||
"default": null,
|
||||
"required": false,
|
||||
"phase": "detail",
|
||||
"empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김"
|
||||
"empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김",
|
||||
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
|
||||
},
|
||||
{
|
||||
"key": "thickness_bottom_m",
|
||||
@@ -1232,7 +1290,8 @@
|
||||
"default": null,
|
||||
"required": false,
|
||||
"phase": "detail",
|
||||
"empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김"
|
||||
"empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김",
|
||||
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
|
||||
},
|
||||
{
|
||||
"key": "back_len_cm",
|
||||
@@ -1242,7 +1301,8 @@
|
||||
"default": null,
|
||||
"required": false,
|
||||
"phase": "detail",
|
||||
"empty_means": "비우면 돌쌓기 기본값으로 서고 「기본값으로 섰음」이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검"
|
||||
"empty_means": "비우면 돌쌓기 기본값으로 서고 「기본값으로 섰음」이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검",
|
||||
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
|
||||
},
|
||||
{
|
||||
"key": "stone_kind",
|
||||
@@ -1252,7 +1312,8 @@
|
||||
"default": null,
|
||||
"required": false,
|
||||
"phase": "detail",
|
||||
"empty_means": "비우면 야면석 계수 열로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검"
|
||||
"empty_means": "비우면 야면석 계수 열로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검",
|
||||
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
|
||||
},
|
||||
{
|
||||
"key": "stone_supply",
|
||||
@@ -1262,7 +1323,8 @@
|
||||
"default": "채집",
|
||||
"required": false,
|
||||
"phase": "detail",
|
||||
"default_basis": "「채집」 — 별표2 「야면석 등은 가급적 현장에서 채취·사용」 권고. 구조물마다 바꿀 수 있음"
|
||||
"default_basis": "「채집」 — 별표2 「야면석 등은 가급적 현장에서 채취·사용」 권고. 구조물마다 바꿀 수 있음",
|
||||
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
|
||||
},
|
||||
{
|
||||
"key": "stone_coeff_basis",
|
||||
@@ -1272,7 +1334,8 @@
|
||||
"default": null,
|
||||
"required": false,
|
||||
"phase": "detail",
|
||||
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀"
|
||||
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀",
|
||||
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
|
||||
},
|
||||
{
|
||||
"key": "fill_concrete_mpa",
|
||||
@@ -1282,7 +1345,8 @@
|
||||
"default": null,
|
||||
"required": false,
|
||||
"phase": "detail",
|
||||
"empty_means": "비우면 계산 쪽 기준 강도로 돎"
|
||||
"empty_means": "비우면 계산 쪽 기준 강도로 돎",
|
||||
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
|
||||
},
|
||||
{
|
||||
"key": "face_slope_ratio",
|
||||
@@ -1291,7 +1355,8 @@
|
||||
"default": null,
|
||||
"required": false,
|
||||
"phase": "detail",
|
||||
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김"
|
||||
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김",
|
||||
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
|
||||
},
|
||||
{
|
||||
"key": "foundation",
|
||||
@@ -1310,7 +1375,8 @@
|
||||
"default": null,
|
||||
"required": false,
|
||||
"phase": "detail",
|
||||
"empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
|
||||
"empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」",
|
||||
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
|
||||
},
|
||||
{
|
||||
"key": "weep_hole_area_m2",
|
||||
@@ -1320,7 +1386,8 @@
|
||||
"default": null,
|
||||
"required": false,
|
||||
"phase": "detail",
|
||||
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
|
||||
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」",
|
||||
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1342,7 +1409,8 @@
|
||||
"unit": "m",
|
||||
"default": 2.5,
|
||||
"required": false,
|
||||
"phase": "b05"
|
||||
"phase": "b05",
|
||||
"empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)"
|
||||
},
|
||||
{
|
||||
"key": "length_m",
|
||||
@@ -1396,7 +1464,8 @@
|
||||
"choices": ["채집", "구입"],
|
||||
"default": "채집",
|
||||
"required": false,
|
||||
"phase": "detail"
|
||||
"phase": "detail",
|
||||
"not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기는 채집석 공제를 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
|
||||
},
|
||||
{
|
||||
"key": "stone_coeff_basis",
|
||||
@@ -1406,7 +1475,8 @@
|
||||
"default": null,
|
||||
"required": false,
|
||||
"phase": "detail",
|
||||
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀"
|
||||
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀",
|
||||
"not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기(13-6)는 계수 열을 아직 안 가름 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
|
||||
},
|
||||
{
|
||||
"key": "fill_concrete_mpa",
|
||||
@@ -1416,7 +1486,8 @@
|
||||
"default": null,
|
||||
"required": false,
|
||||
"phase": "detail",
|
||||
"empty_means": "비우면 계산 쪽 기준 강도로 돎"
|
||||
"empty_means": "비우면 계산 쪽 기준 강도로 돎",
|
||||
"not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기(13-6)는 채움 콘크리트를 품에 포함 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
|
||||
},
|
||||
{
|
||||
"key": "face_slope_ratio",
|
||||
@@ -1452,7 +1523,8 @@
|
||||
"default": null,
|
||||
"required": false,
|
||||
"phase": "detail",
|
||||
"empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
|
||||
"empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」",
|
||||
"not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기는 물빼기 관을 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
|
||||
},
|
||||
{
|
||||
"key": "weep_hole_area_m2",
|
||||
@@ -1462,7 +1534,8 @@
|
||||
"default": null,
|
||||
"required": false,
|
||||
"phase": "detail",
|
||||
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
|
||||
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」",
|
||||
"not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기는 물빼기 관을 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
|
||||
},
|
||||
{
|
||||
"key": "blinding_concrete",
|
||||
@@ -1590,7 +1663,8 @@
|
||||
"default": null,
|
||||
"required": false,
|
||||
"phase": "detail",
|
||||
"empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김"
|
||||
"empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김",
|
||||
"not_in_table": "아직 표에 안 쓰임 — 골막이 두께는 정본 식(ℓ3+0.1H · ℓ3+0.4H)으로만 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
|
||||
},
|
||||
{
|
||||
"key": "thickness_bottom_m",
|
||||
@@ -1600,7 +1674,8 @@
|
||||
"default": null,
|
||||
"required": false,
|
||||
"phase": "detail",
|
||||
"empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김"
|
||||
"empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김",
|
||||
"not_in_table": "아직 표에 안 쓰임 — 골막이 두께는 정본 식(ℓ3+0.1H · ℓ3+0.4H)으로만 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
|
||||
},
|
||||
{
|
||||
"key": "back_len_cm",
|
||||
@@ -1618,7 +1693,8 @@
|
||||
"choices": ["채집", "구입"],
|
||||
"default": "채집",
|
||||
"required": false,
|
||||
"phase": "detail"
|
||||
"phase": "detail",
|
||||
"not_in_table": "아직 표에 안 쓰임 — 골막이는 채집석 공제를 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
|
||||
},
|
||||
{
|
||||
"key": "stone_coeff_basis",
|
||||
@@ -1628,7 +1704,8 @@
|
||||
"default": null,
|
||||
"required": false,
|
||||
"phase": "detail",
|
||||
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀"
|
||||
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀",
|
||||
"not_in_table": "아직 표에 안 쓰임 — 골막이는 계수 열을 아직 안 가름 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
|
||||
},
|
||||
{
|
||||
"key": "fill_concrete_mpa",
|
||||
@@ -1739,7 +1816,8 @@
|
||||
"choices": ["채집", "구입"],
|
||||
"default": "채집",
|
||||
"required": false,
|
||||
"phase": "detail"
|
||||
"phase": "detail",
|
||||
"not_in_table": "아직 표에 안 쓰임 — 바닥막이는 채집석 공제를 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
|
||||
},
|
||||
{
|
||||
"key": "stone_coeff_basis",
|
||||
@@ -1749,7 +1827,8 @@
|
||||
"default": null,
|
||||
"required": false,
|
||||
"phase": "detail",
|
||||
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀"
|
||||
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀",
|
||||
"not_in_table": "아직 표에 안 쓰임 — 바닥막이 원단위는 돌붙임 정본 표 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
|
||||
},
|
||||
{
|
||||
"key": "fill_concrete_mpa",
|
||||
@@ -1759,7 +1838,8 @@
|
||||
"default": null,
|
||||
"required": false,
|
||||
"phase": "detail",
|
||||
"empty_means": "비우면 계산 쪽 기준 강도로 돎"
|
||||
"empty_means": "비우면 계산 쪽 기준 강도로 돎",
|
||||
"not_in_table": "아직 표에 안 쓰임 — 바닥막이 원단위는 돌붙임 정본 표 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
|
||||
},
|
||||
{
|
||||
"key": "face_slope_ratio",
|
||||
@@ -1768,7 +1848,8 @@
|
||||
"default": null,
|
||||
"required": false,
|
||||
"phase": "detail",
|
||||
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김"
|
||||
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김",
|
||||
"not_in_table": "아직 표에 안 쓰임 — 바닥막이는 평면적(돌붙임 ㎡)이라 기울기 몫이 없음 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
|
||||
},
|
||||
{
|
||||
"key": "foundation",
|
||||
@@ -1910,7 +1991,7 @@
|
||||
"default": null,
|
||||
"required": false,
|
||||
"phase": "detail",
|
||||
"empty_means": "비우면 돌쌓기 기본값으로 서고 「기본값으로 섰음」이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검"
|
||||
"empty_means": "비우면 품셈 13-4-4 [주]⑩ 뒷길이 표준 하한(높이·찰/메로 · 범위 안 가장 작은 규격 — 예 메쌓기 1.5~3m 는 36~45㎝ 라 45㎝)으로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검"
|
||||
},
|
||||
{
|
||||
"key": "stone_kind",
|
||||
@@ -1960,6 +2041,16 @@
|
||||
"phase": "detail",
|
||||
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김"
|
||||
},
|
||||
{
|
||||
"key": "face_role",
|
||||
"label": "성토/절토",
|
||||
"input": "select",
|
||||
"choices": ["성토", "절토"],
|
||||
"default": null,
|
||||
"required": false,
|
||||
"phase": "detail",
|
||||
"empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김"
|
||||
},
|
||||
{
|
||||
"key": "foundation",
|
||||
"label": "기초",
|
||||
@@ -1974,9 +2065,10 @@
|
||||
"label": "높이",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 2.5,
|
||||
"default": null,
|
||||
"required": false,
|
||||
"phase": "b05"
|
||||
"phase": "b05",
|
||||
"empty_means": "설계자 입력 — 계획홍수위 + 0.5~0.7m(사방기술교본 2-나:141 · 3-가:181 · 사방(계류) 교본 기준). 비우면 줄만 서고 미확정이라 금액에 안 들어감 · 옛 기본 2.5 는 근거 없어 걷음(2026-09-14 브레인 판정)"
|
||||
},
|
||||
{
|
||||
"key": "length_m",
|
||||
|
||||
@@ -60,6 +60,13 @@ class StructureOptionField(BaseModel):
|
||||
# 기본값이 **도메인 확정값이 아닐 때** 그 뜻을 적는다(예: 「다단 없음」·「안 더함」).
|
||||
# 법정·확정 수치면 비워 둔다 — 비어 있는 것이 「확정값」이라는 뜻이다.
|
||||
default_basis: str | None = None
|
||||
# 이 값을 **넘으면** 칸이 경고색 + 툴팁 — 막지 않음(2026-09-14 브레인 판정 ①·㉰ 「놓기를 막지 말 것」).
|
||||
# 넘는지 보는 기준값과 그 까닭 한 줄(원문 쪽·줄 번호).
|
||||
warn_above: float | None = None
|
||||
warn_message: str | None = None
|
||||
# 칸은 있는데 **원단위 표가 아직 안 읽는** 칸 — 넣어도 수량·금액이 안 바뀜을 칸 옆에 보임(2026-09-14 ①).
|
||||
# 참말인지는 `test_b05_not_in_table_marks` 가 값을 바꿔 돌려 보고 지킴.
|
||||
not_in_table: str | None = None
|
||||
|
||||
|
||||
class StructureType(BaseModel):
|
||||
|
||||
@@ -26,10 +26,6 @@
|
||||
* 1m·길이 2m·각도 45°가 기본이며, 설치를 "없음"으로 바꾸면 제원 칸이 접힌다.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
FORD_BRIDGE_DEFAULT_WIDTH_M,
|
||||
FORD_PAVEMENT_DEFAULT_WIDTH_M,
|
||||
} from "@config/config_frontend";
|
||||
import type {
|
||||
DetailPipeInput,
|
||||
PipeFacility,
|
||||
@@ -42,10 +38,12 @@ import {
|
||||
createRevetmentFields,
|
||||
createRevetSideGroup,
|
||||
createWingFields,
|
||||
fordSection,
|
||||
grid,
|
||||
group,
|
||||
INLET_KINDS,
|
||||
INLET_REVET_KEYS,
|
||||
inletKindOf,
|
||||
type InletStructureKind,
|
||||
labeled,
|
||||
numberInput,
|
||||
optionalSelect,
|
||||
@@ -56,30 +54,10 @@ import {
|
||||
WING_IN_KEYS,
|
||||
WING_OUT_KEYS,
|
||||
} from "./B05_Profile_UI_Drainage_Facility_Fields";
|
||||
import { pipeWallMinHeightM } from "../common_util/common_util_culvert_sets";
|
||||
import { createFordFields } from "./B05_Profile_UI_Drainage_Facility_Ford";
|
||||
|
||||
/** B06 유입측 구조물 형식 — 조정창 드롭다운과 같은 값 집합(2026-08-29 병합).
|
||||
* 문자열 리터럴로 두어 B05가 B06 모듈에 기대지 않게 한다. */
|
||||
export type InletStructureKind = "auto" | "revet" | "I" | "L" | "U";
|
||||
|
||||
/** 유입구 "구조" 목록 — 조정창의 구조물 형식(자동/기슭막이/집수정 I·ㄴ·ㄷ)을 이 한
|
||||
* 드롭다운으로 합쳤다(2026-08-29 사용자 지시 5: 일단 리스트를 합치고 중복은 뒤에 뺀다).
|
||||
* 고른 값 하나가 정본 둘을 정한다 — 관 옵션 `inlet_type`(기슭막이/집수정)과 B06
|
||||
* 유입측 형식(`inlet_structure`). */
|
||||
const INLET_KINDS: ReadonlyArray<{
|
||||
label: string;
|
||||
type: "기슭막이" | "집수정";
|
||||
structure: InletStructureKind;
|
||||
}> = [
|
||||
{ label: "기슭막이", type: "기슭막이", structure: "revet" },
|
||||
{ label: "집수정", type: "집수정", structure: "auto" },
|
||||
{ label: "자동(규칙)", type: "기슭막이", structure: "auto" },
|
||||
{ label: "집수정 I형", type: "집수정", structure: "I" },
|
||||
{ label: "집수정 ㄴ형", type: "집수정", structure: "L" },
|
||||
{ label: "집수정 ㄷ형", type: "집수정", structure: "U" },
|
||||
];
|
||||
|
||||
const inletKindOf = (label: string): (typeof INLET_KINDS)[number] =>
|
||||
INLET_KINDS.find((kind) => kind.label === label) ?? INLET_KINDS[0];
|
||||
export type { InletStructureKind }; // 목록은 700줄 한계로 `_Fields` 로 옮김 — 부르던 곳은 그대로
|
||||
|
||||
/** 시설 확장 정보 한 건 — 관 지점(chainage)에 얹힌다. 배관(pipe)도 부속 옵션을
|
||||
* 가지면 저장한다. */
|
||||
@@ -209,17 +187,13 @@ export function createFacilityOptionsForm(
|
||||
root.hidden = true;
|
||||
|
||||
// ── 배관 본체 — 관종 > 관경 한 행(2026-08-17 사용자 지시 4) ──────────────
|
||||
// 관종 기본값 = 파형강관(2026-08-17 사용자 확정) — 빈 항목 없이 셋 중 하나.
|
||||
const pipeMaterial = document.createElement("select");
|
||||
pipeMaterial.replaceChildren(
|
||||
...["흄관", "VR관", "파형강관"].map((value) => new Option(value, value)),
|
||||
);
|
||||
pipeMaterial.value = "파형강관";
|
||||
// 관종 파형강관·관경 1000(사용자 확정 기본)은 **제안으로만** — 「안 정함」이 실제 상태(판정 ③).
|
||||
const pipeMaterial = optionalSelect("— 안 정함 (제안 파형강관) —", ["흄관", "VR관", "파형강관"]);
|
||||
const pipeDiameter = document.createElement("select");
|
||||
pipeDiameter.replaceChildren(
|
||||
new Option("— 안 정함 (제안 Ø1000) —", ""),
|
||||
...["800", "1000", "1200", "1500"].map((size) => new Option(`Ø${size}`, size)),
|
||||
);
|
||||
pipeDiameter.value = "1000"; // 별표2 원칙값 + 사용자 확정 기본 (2026-08-17)
|
||||
// 관보호공 날개벽 — **개소당 원단위**가 형식마다 붙박이라 고르는 칸이 있어야 물량이 선다
|
||||
// (2026-09-09, 계획서 4-13 · 소광리 원본 탭 다섯). 비우면 날개벽을 안 센다.
|
||||
const wingWall = optionalSelect("안 놓음", ["A-TYPE", "C-TYPE", "A-TYPE+집수정"]);
|
||||
@@ -228,11 +202,12 @@ export function createFacilityOptionsForm(
|
||||
|
||||
// ── 유입구 — 집수정 또는 기슭막이 택일(사용자 지시 5~9) ─────────────────
|
||||
const inletGroup = group("유입구");
|
||||
// 빈값(미지정)을 두면 하위 칸이 전부 숨어 기본값이 안 보인다 — 기슭막이를 기본으로
|
||||
// 두고 저유량 지점에서 집수정으로 바꾼다(2026-08-17 사용자 지시).
|
||||
// 「안 정함」이면 기슭막이 칸을 보이되(제안) 값은 안 실음 — 기본값을 몰래 확정으로 안 바꿈(판정 ③).
|
||||
const inletType = document.createElement("select");
|
||||
inletType.replaceChildren(...INLET_KINDS.map((kind) => new Option(kind.label, kind.label)));
|
||||
inletType.value = INLET_KINDS[0].label;
|
||||
inletType.replaceChildren(
|
||||
new Option("— 안 정함 (제안 기슭막이) —", ""),
|
||||
...INLET_KINDS.map((kind) => new Option(kind.label, kind.label)),
|
||||
);
|
||||
const basinForm = optionalSelect("선택", [
|
||||
"동물이동형",
|
||||
"□형(기본형)",
|
||||
@@ -248,14 +223,16 @@ export function createFacilityOptionsForm(
|
||||
]);
|
||||
const basinMaterialField = labeled("형태", basinMaterial);
|
||||
// 집수정 종방향 길이 — 3D 예상형상용, 전후 동일 배분·기본 2m(2026-08-23 사용자).
|
||||
const basinLength = numberInput("0.1");
|
||||
basinLength.value = "2";
|
||||
const basinLength = numberInput("0.1", "0", "제안 2");
|
||||
const basinRows = [
|
||||
grid(labeled("형식", basinForm), labeled("길이 (m)", stepper(basinLength, 1))),
|
||||
];
|
||||
const height = (): string =>
|
||||
String(pipeWallMinHeightM(Number(pipeDiameter.value || "1000") / 1000));
|
||||
const inletRevet = createRevetmentFields(INLET_REVET_KEYS, {
|
||||
...REVET_COMMON_DEFAULTS,
|
||||
form: "돌쌓기(찰)",
|
||||
height,
|
||||
});
|
||||
// 첫 행 = [구조][형태] — 구조에 따라 집수정 형태와 기슭막이 형태가 갈아 끼워진다
|
||||
// (2026-08-17 사용자 지시 3).
|
||||
@@ -273,11 +250,11 @@ export function createFacilityOptionsForm(
|
||||
|
||||
// ── 유출구 — 구조 선택지는 기슭막이뿐이지만 양식을 맞춘다(사용자 지시 2·4) ──
|
||||
const outletGroup = group("유출구");
|
||||
const outletType = document.createElement("select");
|
||||
outletType.replaceChildren(new Option("기슭막이", "기슭막이"));
|
||||
const outletType = optionalSelect("— 안 정함 (제안 기슭막이) —", ["기슭막이"]);
|
||||
const outletRevet = createRevetmentFields(OUTLET_REVET_KEYS, {
|
||||
...REVET_COMMON_DEFAULTS,
|
||||
form: "돌쌓기(메)",
|
||||
height,
|
||||
});
|
||||
outletGroup.body.append(
|
||||
grid(labeled("구조", outletType), outletRevet.formField),
|
||||
@@ -299,8 +276,7 @@ export function createFacilityOptionsForm(
|
||||
// 나눈다(2026-08-30 사용자). 설치 측이 한쪽이면 그 칸만 뜬다.
|
||||
// 좌·우가 저장 채널(유입/유출) 중 어느 쪽인지는 측점 지형이 정하므로 이름표만
|
||||
// 바꿔 단다(`setRevetSideLabels`) — 값은 언제나 그 채널 키로 오간다.
|
||||
const revetSide = optionalSelect("양쪽", ["양쪽", "좌", "우"]);
|
||||
revetSide.value = "양쪽";
|
||||
const revetSide = optionalSelect("— 안 정함 (제안 양쪽) —", ["양쪽", "좌", "우"]);
|
||||
const revetTopRow = grid(labeled("설치 측", revetSide));
|
||||
const revetInlet = createRevetSideGroup(INLET_REVET_KEYS, "돌쌓기(메)");
|
||||
const revetOutlet = createRevetSideGroup(OUTLET_REVET_KEYS, "돌쌓기(메)");
|
||||
@@ -338,12 +314,12 @@ export function createFacilityOptionsForm(
|
||||
|
||||
// ── BOX암거 — 본체 규격(프리셋 + 사용자 지정)·날개벽(유입·유출 개별) ────
|
||||
// 폭·높이 자유 입력은 "사용자 지정"을 골랐을 때만 펼친다(2026-08-17 사용자 확정).
|
||||
const boxSize = optionalSelect("미지정", [
|
||||
const boxSize = optionalSelect(`— 안 정함 (제안 ${BOX_SIZE_PRESETS[0][0]}) —`, [
|
||||
...BOX_SIZE_PRESETS.map(([label]) => label),
|
||||
BOX_SIZE_CUSTOM,
|
||||
]);
|
||||
const boxWidth = numberInput("0.1");
|
||||
const boxHeight = numberInput("0.1");
|
||||
const boxWidth = numberInput("0.1", "0", `제안 ${BOX_SIZE_PRESETS[0][1]}`);
|
||||
const boxHeight = numberInput("0.1", "0", `제안 ${BOX_SIZE_PRESETS[0][2]}`);
|
||||
const boxCustomRow = grid(labeled("본체 폭 (m)", boxWidth), labeled("본체 높이 (m)", boxHeight));
|
||||
const boxWrap = document.createElement("div");
|
||||
boxWrap.append(grid(labeled("본체 규격 (m)", boxSize)), boxCustomRow);
|
||||
@@ -358,98 +334,50 @@ 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();
|
||||
});
|
||||
|
||||
// 10-A ⑲ 저장 규칙을 폼 머리에 드러냄 — 병합은 `_Drainage_Facility_Merge`(2026-09-14 브레인 판정).
|
||||
const saveNote = document.createElement("p");
|
||||
saveNote.className = "b05-structure__owner-note";
|
||||
saveNote.textContent =
|
||||
"[저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀";
|
||||
|
||||
// 세월교·물넘이 항목은 **관종·관경 바로 다음**에 둔다(2026-08-30 사용자 지시 2) —
|
||||
// 월류 폭·높이 → 바닥 경사 → 수량 → 개략 단면 결과. 다른 시설에서는 전부 숨는다.
|
||||
root.append(
|
||||
grid(suggest),
|
||||
saveNote,
|
||||
pipeRow,
|
||||
wingRow,
|
||||
fordWidthRow,
|
||||
fordSlopeRow,
|
||||
fordRow,
|
||||
fordSummary,
|
||||
ford.widthRow,
|
||||
ford.slopeRow,
|
||||
ford.countRow,
|
||||
ford.summary,
|
||||
inletGroup.root,
|
||||
outletGroup.root,
|
||||
extraGroup.root,
|
||||
@@ -480,24 +408,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 +443,6 @@ export function createFacilityOptionsForm(
|
||||
}
|
||||
}
|
||||
|
||||
/** 월류 높이는 cm 단위 수심이라 0.01m 정밀도로 싣는다(putNumber는 0.1m 반올림). */
|
||||
function putFordHeight(options: Record<string, string | number>): void {
|
||||
const value = Number.parseFloat(fordHeight.value);
|
||||
if (Number.isFinite(value) && value > 0) options.ford_height_m = Number(value.toFixed(2));
|
||||
}
|
||||
|
||||
function emit(): void {
|
||||
syncVisibility();
|
||||
callbacks.onChange?.();
|
||||
@@ -543,8 +464,7 @@ export function createFacilityOptionsForm(
|
||||
boxHeight,
|
||||
...wingInFields.inputs,
|
||||
...wingOutFields.inputs,
|
||||
fordCount,
|
||||
fordWidth,
|
||||
...ford.inputs,
|
||||
].forEach((input) => input.addEventListener("change", emit));
|
||||
|
||||
/** 유입구 구조 변경 수신자(B06 유입측 형식 제어). B05는 비워 둔다. */
|
||||
@@ -581,35 +501,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 +544,38 @@ export function createFacilityOptionsForm(
|
||||
Math.abs(width - savedWidth) < 0.05 && Math.abs(height - savedHeight) < 0.05,
|
||||
)
|
||||
: undefined;
|
||||
boxSize.value = hasSaved ? (preset?.[0] ?? BOX_SIZE_CUSTOM) : firstLabel;
|
||||
boxWidth.value = String(hasSaved ? savedWidth : firstWidth);
|
||||
boxHeight.value = String(hasSaved ? savedHeight : firstHeight);
|
||||
boxSize.value = hasSaved ? (preset?.[0] ?? BOX_SIZE_CUSTOM) : "";
|
||||
boxWidth.value = hasSaved ? String(savedWidth) : "";
|
||||
boxHeight.value = hasSaved ? String(savedHeight) : "";
|
||||
wingInFields.write(options);
|
||||
wingOutFields.write(options);
|
||||
fordCount.value = isFord ? text("pipe_count") : "";
|
||||
fordWidth.value = text("ford_width_m");
|
||||
fordHeight.value = text("ford_height_m");
|
||||
// 새 시설을 올리는 참이다 — 저장된 높이는 사용자 값으로 보고 자동 추적을 끊는다.
|
||||
fordAutoDepthM = null;
|
||||
fordSlope.value = text("ford_slope_pct");
|
||||
revetSide.value = text("side") || "양쪽";
|
||||
if (facility === "ford_pavement" || facility === "ford_bridge") ford.write(facility, options);
|
||||
revetSide.value = text("side");
|
||||
const spread = legacyRevetOptions(options);
|
||||
revetInlet.fields.write(spread);
|
||||
revetOutlet.fields.write(spread);
|
||||
// 월류 폭 기본값 — 세월교 10m·물넘이 포장 5m(2026-08-18 사용자 확정, config 정의처).
|
||||
if (!fordWidth.value && (facility === "ford_pavement" || facility === "ford_bridge")) {
|
||||
fordWidth.value = String(
|
||||
facility === "ford_bridge" ? FORD_BRIDGE_DEFAULT_WIDTH_M : FORD_PAVEMENT_DEFAULT_WIDTH_M,
|
||||
);
|
||||
}
|
||||
syncVisibility();
|
||||
},
|
||||
setDesignFlow(designFlow) {
|
||||
designFlowM3s = designFlow ?? null;
|
||||
ford.setDesignFlow(designFlow ?? null);
|
||||
syncVisibility();
|
||||
},
|
||||
readOptions() {
|
||||
const options: Record<string, string | number> = {};
|
||||
// 고른 칸만 싣는다 — 「안 정함」은 빈 값(기본값을 몰래 확정으로 안 바꿈).
|
||||
const putPipe = (): void => {
|
||||
if (pipeDiameter.value) options.pipe_diameter_mm = Number(pipeDiameter.value);
|
||||
if (pipeMaterial.value) options.pipe_kind = pipeMaterial.value;
|
||||
};
|
||||
if (current === "pipe") {
|
||||
options.pipe_diameter_mm = Number(pipeDiameter.value);
|
||||
options.pipe_kind = pipeMaterial.value;
|
||||
putPipe();
|
||||
const inletKind = inletKindOf(inletType.value);
|
||||
if (wingWall.value) options.wing_wall_type = wingWall.value;
|
||||
options.inlet_type = inletKind.type;
|
||||
// B06 유입측 형식(조정창과 같은 값) — 병합 드롭다운이 함께 정한다(2026-08-29).
|
||||
options.inlet_structure = inletKind.structure;
|
||||
if (inletType.value) {
|
||||
options.inlet_type = inletKind.type;
|
||||
options.inlet_structure = inletKind.structure;
|
||||
}
|
||||
if (inletKind.type === "집수정") {
|
||||
if (basinForm.value) options.inlet_basin_form = basinForm.value;
|
||||
if (basinMaterial.value) options.inlet_basin_material = basinMaterial.value;
|
||||
@@ -667,7 +583,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 +592,14 @@ export function createFacilityOptionsForm(
|
||||
wingInFields.read(options);
|
||||
wingOutFields.read(options);
|
||||
} else if (current === "ford_pavement") {
|
||||
putNumber(options, "ford_width_m", fordWidth.value);
|
||||
putFordHeight(options);
|
||||
putNumber(options, "ford_slope_pct", fordSlope.value);
|
||||
ford.read(current, options);
|
||||
} else if (current === "ford_bridge") {
|
||||
options.pipe_kind = pipeMaterial.value;
|
||||
options.pipe_diameter_mm = Number(pipeDiameter.value);
|
||||
const count = Number.parseInt(fordCount.value, 10);
|
||||
if (Number.isFinite(count) && count > 0) options.pipe_count = count;
|
||||
putNumber(options, "ford_width_m", fordWidth.value);
|
||||
putFordHeight(options);
|
||||
putPipe();
|
||||
ford.read(current, options);
|
||||
wingInFields.read(options);
|
||||
wingOutFields.read(options);
|
||||
} else if (current === "revetment") {
|
||||
options.side = revetSide.value;
|
||||
if (revetSide.value) options.side = revetSide.value;
|
||||
// 단 수는 폼이 갖지 않는다 — 횡단 설계 patch(extra_wall_counts)가 정본이고
|
||||
// 조작은 [추가 기슭막이(단)] 행이 한다(2026-08-30 사용자: 중복이라 삭제).
|
||||
// 좌·우가 각자 값을 갖는다 — 저장 채널은 유입/유출 키 그대로다(2026-08-30).
|
||||
|
||||
@@ -9,6 +9,33 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { FORD_MANNING_N, FORD_SLOPE } from "@config/config_frontend";
|
||||
import revetmentSabang from "../resources/data_masonry/revetment_sabang_2026-01-01.json";
|
||||
|
||||
/* ── 유입구 구조 ──────────────────────────────────────────────────────────── */
|
||||
|
||||
/** B06 유입측 구조물 형식 — 조정창 드롭다운과 같은 값 집합(2026-08-29 병합).
|
||||
* 문자열 리터럴로 두어 B05가 B06 모듈에 기대지 않게 한다. */
|
||||
export type InletStructureKind = "auto" | "revet" | "I" | "L" | "U";
|
||||
|
||||
/** 유입구 "구조" 목록 — 조정창의 구조물 형식(자동/기슭막이/집수정 I·ㄴ·ㄷ)을 이 한
|
||||
* 드롭다운으로 합쳤다(2026-08-29 사용자 지시 5: 일단 리스트를 합치고 중복은 뒤에 뺀다).
|
||||
* 고른 값 하나가 정본 둘을 정한다 — 관 옵션 `inlet_type`(기슭막이/집수정)과 B06
|
||||
* 유입측 형식(`inlet_structure`). */
|
||||
export const INLET_KINDS: ReadonlyArray<{
|
||||
label: string;
|
||||
type: "기슭막이" | "집수정";
|
||||
structure: InletStructureKind;
|
||||
}> = [
|
||||
{ label: "기슭막이", type: "기슭막이", structure: "revet" },
|
||||
{ label: "집수정", type: "집수정", structure: "auto" },
|
||||
{ label: "자동(규칙)", type: "기슭막이", structure: "auto" },
|
||||
{ label: "집수정 I형", type: "집수정", structure: "I" },
|
||||
{ label: "집수정 ㄴ형", type: "집수정", structure: "L" },
|
||||
{ label: "집수정 ㄷ형", type: "집수정", structure: "U" },
|
||||
];
|
||||
|
||||
export const inletKindOf = (label: string): (typeof INLET_KINDS)[number] =>
|
||||
INLET_KINDS.find((kind) => kind.label === label) ?? INLET_KINDS[0];
|
||||
|
||||
/* ── 물넘이·세월교 개략 단면 ────────────────────────────────────────────── */
|
||||
|
||||
@@ -127,14 +154,18 @@ export function putNumber(target: Record<string, string | number>, key: string,
|
||||
if (Number.isFinite(value) && value > 0) target[key] = Number(value.toFixed(1));
|
||||
}
|
||||
|
||||
/** 선택지 셀렉트 — 빈("미지정") 항목을 두지 않는다. 첫 항목이 곧 기본값이고,
|
||||
* 기본값은 구조물별로 따로 지정한다(2026-08-17 사용자 지시 1). */
|
||||
/** 선택지 셀렉트 — **맨 앞에 빈 보기(「안 정함」)** 를 둠. 첫 보기를 미리 고르지 않음
|
||||
* (2026-09-14 브레인 판정 · 옛 2026-08-17 「빈 항목 두지 않음」 걷음) — 고른 적 없는 첫 값이 저장돼
|
||||
* 확정으로 굳지 않게. 기본값이 있는 칸은 부르는 쪽이 값을 넣음(그것은 따로 차례). */
|
||||
export function optionalSelect(
|
||||
_blankLabel: string,
|
||||
blankLabel: string,
|
||||
choices: ReadonlyArray<string>,
|
||||
): HTMLSelectElement {
|
||||
const element = document.createElement("select");
|
||||
element.replaceChildren(...choices.map((choice) => new Option(choice, choice)));
|
||||
element.replaceChildren(
|
||||
new Option(blankLabel, ""),
|
||||
...choices.map((choice) => new Option(choice, choice)),
|
||||
);
|
||||
return element;
|
||||
}
|
||||
|
||||
@@ -175,16 +206,20 @@ export interface RevetmentKeys {
|
||||
after?: string;
|
||||
}
|
||||
|
||||
/** 부위별 기본 구성 — 형태는 유입 돌쌓기(찰)·유출 돌쌓기(메), 길이 10m·높이 2.5m. */
|
||||
/** 부위별 **제안값** — 형태는 유입 돌쌓기(찰)·유출 돌쌓기(메), 길이 10m. 관 벽 높이는 관경 기준
|
||||
* (함수로 받아 볼 때마다 셈).
|
||||
* ⚠ 제안값은 **칸에 채우지 않고 회색 글씨로만** 보임 — 사용자가 적거나 [제안값 넣기]를 누른 때만
|
||||
* 값이 됨(2026-09-14 브레인 판정 「기본값을 몰래 확정으로 바꾸지 않는다」). 칸에 채워 두면 저장이
|
||||
* 그 값을 적어 **미확정이 저장 한 번에 확정으로 바뀜** — 사용자가 한 일이 없는데 금액이 섬. */
|
||||
export interface RevetmentDefaults {
|
||||
form: string;
|
||||
length: string;
|
||||
height: string;
|
||||
height: string | (() => string);
|
||||
before: string;
|
||||
after: string;
|
||||
}
|
||||
|
||||
/** 부위와 무관한 공통 기본 치수 — 길이 10m(전 5·후 5)·높이 2.5m. */
|
||||
/** 부위와 무관한 공통 제안 치수 — 길이 10m(전 5·후 5)·높이 2.5m(독립 기슭막이 · 관 벽은 관경 기준). */
|
||||
export const REVET_COMMON_DEFAULTS = {
|
||||
length: "10",
|
||||
height: "2.5",
|
||||
@@ -218,16 +253,43 @@ export interface RevetSideGroup {
|
||||
slot: HTMLElement;
|
||||
}
|
||||
|
||||
/** 독립 기슭막이 치수 근거 줄 — `revetment_sabang_*.json` 한 벌(B08 사유와 같은 파일 · 쪽·줄 번호).
|
||||
* ⚠ 파일 판이 바뀌면 이 import 도 옮길 것 — 시험(`test_b05_revetment_sabang_basis`)이 최신 판을 대조. */
|
||||
function sabangBasisLines(): string[] {
|
||||
const { items, scope } = revetmentSabang;
|
||||
const span = (values: number[], unit = ""): string => `${values[0]}~${values[1]}${unit}`;
|
||||
return [
|
||||
`높이 — ${items.height.rule}(${items.height.source}) · ${items.height.input}`,
|
||||
`계획비탈 1:${span(items.face_slope.standard_ratio)}(${items.face_slope.source}) — 제안값은 품셈 표준경사`,
|
||||
`둑마루 두께 ${span(items.crown_thickness.standard_m, "m")}(${items.crown_thickness.source}) — 콘크리트 기준 · 돌쌓기는 뒷길이 + 0.30 식`,
|
||||
`뒷채움 조약돌 두께 — ${items.backfill_pebble.source}`,
|
||||
scope,
|
||||
];
|
||||
}
|
||||
|
||||
export function createRevetSideGroup(keys: RevetmentKeys, formDefault: string): RevetSideGroup {
|
||||
const box = group("기슭막이");
|
||||
const kind = document.createElement("select");
|
||||
kind.replaceChildren(new Option("기슭막이", "기슭막이"));
|
||||
const fields = createRevetmentFields(keys, { ...REVET_COMMON_DEFAULTS, form: formDefault });
|
||||
// 높이는 제안 없음 — 설계자 입력(계획홍수위 + 여유고 · 2026-09-14 브레인 판정). 옛 2.5 는 근거 없어 걷음.
|
||||
const fields = createRevetmentFields(keys, {
|
||||
...REVET_COMMON_DEFAULTS,
|
||||
form: formDefault,
|
||||
height: "",
|
||||
});
|
||||
const basis = document.createElement("p");
|
||||
basis.className = "b05-route__note";
|
||||
basis.replaceChildren(
|
||||
...sabangBasisLines().flatMap((line, index) =>
|
||||
index ? [document.createElement("br"), line] : [line],
|
||||
),
|
||||
);
|
||||
const slot = document.createElement("div");
|
||||
slot.className = "b05-structure__adjust-slot";
|
||||
box.body.append(
|
||||
grid(labeled("구조", kind), fields.formField),
|
||||
...fields.rows,
|
||||
basis,
|
||||
// 단 수는 여기 두지 않는다 — 조정창에서 옮겨 오는 [추가 기슭막이(단)] 행과
|
||||
// 같은 값이라 두 벌이면 헷갈린다(2026-08-30 사용자).
|
||||
slot,
|
||||
@@ -258,26 +320,32 @@ export function createRevetmentFields(
|
||||
keys: RevetmentKeys,
|
||||
defaults: RevetmentDefaults,
|
||||
): RevetmentFields {
|
||||
// ⚠ 제안값은 **회색 글씨(placeholder)로만** — 칸은 비워 둠(`RevetmentDefaults` 주석).
|
||||
const suggestHeight = (): string =>
|
||||
typeof defaults.height === "function" ? defaults.height() : defaults.height;
|
||||
const form = document.createElement("select");
|
||||
form.replaceChildren(...REVET_FORMS.map((value) => new Option(value, value)));
|
||||
form.value = defaults.form;
|
||||
const length = keys.length ? numberInput("0.1") : null;
|
||||
if (length) length.value = defaults.length;
|
||||
const height = numberInput("0.1");
|
||||
height.value = defaults.height;
|
||||
form.replaceChildren(
|
||||
new Option(`제안 ${defaults.form}`, ""),
|
||||
...REVET_FORMS.map((value) => new Option(value, value)),
|
||||
);
|
||||
const length = keys.length ? numberInput("0.1", "0", `제안 ${defaults.length}`) : null;
|
||||
// 제안이 없는 칸(독립 기슭막이 높이)은 「설계자 입력」 — 값을 지어내지 않음.
|
||||
const heightHint = (): string => (suggestHeight() ? `제안 ${suggestHeight()}` : "설계자 입력");
|
||||
const height = numberInput("0.1", "0", heightHint());
|
||||
// 관 벽 높이 제안은 관경을 따라감 — 칸을 볼 때마다 다시 셈.
|
||||
height.addEventListener("focus", () => (height.placeholder = heightHint()));
|
||||
// 기준측점 전/후(2026-08-23 사용자) — 구조물 패널(D군)과 같은 연동:
|
||||
// 전 + 후 = 길이, 나중에 고친 쪽이 살아남고 반대쪽이 재배분된다.
|
||||
const before = keys.before && length ? numberInput("0.1") : null;
|
||||
const after = keys.after && length ? numberInput("0.1") : null;
|
||||
if (before) before.value = defaults.before;
|
||||
if (after) after.value = defaults.after;
|
||||
const before = keys.before && length ? numberInput("0.1", "0", `제안 ${defaults.before}`) : null;
|
||||
const after = keys.after && length ? numberInput("0.1", "0", `제안 ${defaults.after}`) : null;
|
||||
let lastSplitEdit: "before" | "after" = "before";
|
||||
const readNumber = (input: HTMLInputElement | null): number => {
|
||||
const value = input ? Number.parseFloat(input.value) : Number.NaN;
|
||||
return Number.isFinite(value) && value >= 0 ? value : 0;
|
||||
};
|
||||
const syncSplit = (source: "length" | "before" | "after"): void => {
|
||||
if (!length || !before || !after) return;
|
||||
// 길이가 비었으면 전/후를 0 으로 채우지 않음 — 빈 칸이 「0 으로 적음」이 되면 안 됨.
|
||||
if (!length || !before || !after || length.value === "") return;
|
||||
if (source !== "length") lastSplitEdit = source;
|
||||
const total = readNumber(length);
|
||||
const keep = lastSplitEdit === "after" ? after : before;
|
||||
@@ -293,10 +361,25 @@ export function createRevetmentFields(
|
||||
const beforeField = before ? labeled("기준측점 전 (m)", stepper(before, 0.5)) : null;
|
||||
const afterField = after ? labeled("기준측점 후 (m)", stepper(after, 0.5)) : null;
|
||||
|
||||
// [제안값 넣기] — **누른 때만** 빈 칸에 제안값을 넣음(적은 칸은 안 건드림) · 층따기 단추와 같은 모양.
|
||||
const fill = document.createElement("button");
|
||||
fill.type = "button";
|
||||
fill.className = "b05-structure__suggest";
|
||||
fill.textContent = "제안값 넣기";
|
||||
fill.addEventListener("click", () => {
|
||||
if (!form.value) form.value = defaults.form;
|
||||
if (length && !length.value) length.value = defaults.length;
|
||||
if (!height.value) height.value = suggestHeight();
|
||||
if (before && !before.value) before.value = defaults.before;
|
||||
if (after && !after.value) after.value = defaults.after;
|
||||
form.dispatchEvent(new Event("change"));
|
||||
});
|
||||
|
||||
// 형태는 그룹 첫 행(구조 옆)에 따로 놓이므로 여기 행에서는 뺀다.
|
||||
const rows = [
|
||||
grid(...[lengthField, heightField].filter((item): item is HTMLLabelElement => item !== null)),
|
||||
...(beforeField && afterField ? [grid(beforeField, afterField)] : []),
|
||||
grid(fill),
|
||||
];
|
||||
|
||||
return {
|
||||
@@ -304,13 +387,15 @@ export function createRevetmentFields(
|
||||
rows,
|
||||
lengthInput: length,
|
||||
write(options) {
|
||||
// 저장된 값만 칸에 — 없으면 비워 두고 제안값은 회색 글씨로(몰래 채우지 않음).
|
||||
const text = (key: string): string =>
|
||||
options[key] !== undefined ? String(options[key]) : "";
|
||||
form.value = text(keys.form) || defaults.form;
|
||||
if (length && keys.length) length.value = text(keys.length) || defaults.length;
|
||||
height.value = text(keys.height) || defaults.height;
|
||||
if (before && keys.before) before.value = text(keys.before) || defaults.before;
|
||||
if (after && keys.after) after.value = text(keys.after) || defaults.after;
|
||||
form.value = text(keys.form);
|
||||
if (length && keys.length) length.value = text(keys.length);
|
||||
height.value = text(keys.height);
|
||||
height.placeholder = heightHint();
|
||||
if (before && keys.before) before.value = text(keys.before);
|
||||
if (after && keys.after) after.value = text(keys.after);
|
||||
syncSplit("length");
|
||||
},
|
||||
read(target) {
|
||||
@@ -395,19 +480,18 @@ interface WingFields {
|
||||
syncVisibility: () => void;
|
||||
write: (options: Record<string, string | number>) => void;
|
||||
read: (target: Record<string, string | number>) => void;
|
||||
/** [제안값 넣기] — 빈 칸에만 기본 제원을 넣음. */
|
||||
fillSuggested: () => void;
|
||||
}
|
||||
|
||||
/** 날개벽 한 벌(설치 유무 + 짧은쪽 높이·길이·각도) — 유입·유출이 공유하는 조각.
|
||||
* 설치 "없음"이면 제원 칸을 접는다(기슭막이 보호공 치수와 같은 패턴). */
|
||||
* 설치 "없음"이면 제원 칸을 접는다(기슭막이 보호공 치수와 같은 패턴).
|
||||
* ⚠ 기본 제원(있음·1·2·45, 사용자 확정)은 **제안으로만** — 칸은 비워 둠(2026-09-14 판정 ③). */
|
||||
export function createWingFields(title: string, keys: WingKeys): WingFields {
|
||||
const install = optionalSelect("미지정", ["있음", "없음"]);
|
||||
install.value = WING_DEFAULTS.install;
|
||||
const height = numberInput("0.1");
|
||||
height.value = WING_DEFAULTS.height;
|
||||
const length = numberInput("0.1");
|
||||
length.value = WING_DEFAULTS.length;
|
||||
const angle = numberInput("1");
|
||||
angle.value = WING_DEFAULTS.angle;
|
||||
const install = optionalSelect(`— 안 정함 (제안 ${WING_DEFAULTS.install}) —`, ["있음", "없음"]);
|
||||
const height = numberInput("0.1", "0", `제안 ${WING_DEFAULTS.height}`);
|
||||
const length = numberInput("0.1", "0", `제안 ${WING_DEFAULTS.length}`);
|
||||
const angle = numberInput("1", "0", `제안 ${WING_DEFAULTS.angle}`);
|
||||
|
||||
// 숫자 칸은 기슭막이·집수정과 같은 [-][값][+] 묶음으로 맞춘다(2026-08-30 사용자).
|
||||
const heightField = labeled("짧은쪽 높이 (m)", stepper(height, 0.1));
|
||||
@@ -426,7 +510,6 @@ export function createWingFields(title: string, keys: WingKeys): WingFields {
|
||||
dimsRow.hidden = off;
|
||||
}
|
||||
|
||||
// 만들자마자 기본값("있음") 상태를 맞춘다.
|
||||
syncVisibility();
|
||||
|
||||
return {
|
||||
@@ -437,18 +520,25 @@ export function createWingFields(title: string, keys: WingKeys): WingFields {
|
||||
write(options) {
|
||||
const text = (key: string): string =>
|
||||
options[key] !== undefined ? String(options[key]) : "";
|
||||
install.value = text(keys.install) || WING_DEFAULTS.install;
|
||||
height.value = text(keys.height) || WING_DEFAULTS.height;
|
||||
length.value = text(keys.length) || WING_DEFAULTS.length;
|
||||
angle.value = text(keys.angle) || WING_DEFAULTS.angle;
|
||||
install.value = text(keys.install);
|
||||
height.value = text(keys.height);
|
||||
length.value = text(keys.length);
|
||||
angle.value = text(keys.angle);
|
||||
syncVisibility();
|
||||
},
|
||||
read(target) {
|
||||
target[keys.install] = install.value;
|
||||
if (install.value !== "있음") return;
|
||||
if (install.value) target[keys.install] = install.value;
|
||||
if (install.value === "없음") return;
|
||||
putNumber(target, keys.height, height.value);
|
||||
putNumber(target, keys.length, length.value);
|
||||
putNumber(target, keys.angle, angle.value);
|
||||
},
|
||||
fillSuggested() {
|
||||
if (!install.value) install.value = WING_DEFAULTS.install;
|
||||
if (!height.value) height.value = WING_DEFAULTS.height;
|
||||
if (!length.value) length.value = WING_DEFAULTS.length;
|
||||
if (!angle.value) angle.value = WING_DEFAULTS.angle;
|
||||
syncVisibility();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Drainage_Facility_Ford.ts
|
||||
* 물넘이포장·세월교 칸 묶음 — 월류 폭·높이·바닥 경사·포장 두께·길이·련 수 + 개략 단면 줄.
|
||||
*
|
||||
* 서브폼 본체(`_Drainage_Facility.ts`)가 700줄에 닿아 떼어 냄(2026-09-14).
|
||||
* ⚠ 기본값을 칸에 안 채움(2026-09-14 브레인 판정 「기본값을 몰래 확정으로 바꾸지 않는다」):
|
||||
* 월류 폭 세월교 10m·물넘이 5m(사용자 확정)는 **회색 제안**, 월류 높이는 설계유량·폭으로 되짚은
|
||||
* **필요 최소 수심을 회색 제안**으로만 — 사용자가 적거나 [제안값 넣기]를 누른 때만 값.
|
||||
* 적은 높이가 필요 수심보다 낮으면 여전히 되돌림(계산값 미만은 안 받음).
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
FORD_BRIDGE_DEFAULT_WIDTH_M,
|
||||
FORD_PAVEMENT_DEFAULT_WIDTH_M,
|
||||
} from "@config/config_frontend";
|
||||
import {
|
||||
fordSection,
|
||||
grid,
|
||||
labeled,
|
||||
numberInput,
|
||||
putNumber,
|
||||
stepper,
|
||||
} from "./B05_Profile_UI_Drainage_Facility_Fields";
|
||||
|
||||
type FordKind = "ford_pavement" | "ford_bridge";
|
||||
|
||||
export interface FordFields {
|
||||
widthRow: HTMLElement;
|
||||
slopeRow: HTMLElement;
|
||||
countRow: HTMLElement;
|
||||
summary: HTMLElement;
|
||||
/** 바뀌면 저장 흐름을 울릴 칸(높이는 자기 검사 뒤 스스로 울림). */
|
||||
inputs: HTMLInputElement[];
|
||||
setDesignFlow: (designFlow: number | null) => void;
|
||||
sync: () => void;
|
||||
write: (kind: FordKind, options: Record<string, string | number>) => void;
|
||||
read: (kind: FordKind, target: Record<string, string | number>) => void;
|
||||
fillSuggested: (kind: FordKind) => void;
|
||||
}
|
||||
|
||||
export function createFordFields(emit: () => void): FordFields {
|
||||
// 세월교 — 구체 내 배관 수량(련). 련은 정수라 소수 자릿수를 두지 않는다.
|
||||
const count = numberInput("1", "1", "련");
|
||||
const countRow = grid(labeled("수량 (련)", stepper(count, 1, 0)));
|
||||
const width = numberInput("0.1");
|
||||
const height = numberInput("0.01");
|
||||
const widthRow = grid(
|
||||
labeled("월류 폭 (m)", stepper(width, 0.1)),
|
||||
labeled("월류 높이 (m)", stepper(height, 0.1)),
|
||||
);
|
||||
// 물넘이 바닥은 유입(상류)이 높고 유출이 낮게 기운다(2026-08-28 사용자 확정).
|
||||
// 비우면 그 측점의 **노면 횡단경사**를 그대로 쓴다 — 횡단도가 판단한다.
|
||||
const slope = numberInput("0.1");
|
||||
slope.placeholder = "노면 기울기";
|
||||
// 포장 두께·노폭 방향 길이 — 수량(㎡ = 월류 폭 × 길이 · 두께로 원단위 고름)이 읽는 칸(A3).
|
||||
const thickness = numberInput("1");
|
||||
const length = numberInput("0.1");
|
||||
const slopeRow = grid(
|
||||
labeled("바닥 경사 유입→유출 (%)", stepper(slope, 0.1)),
|
||||
labeled("포장 두께 (㎝)", stepper(thickness, 1, 0)),
|
||||
labeled("포장 길이 노폭 방향 (m)", stepper(length, 0.1)),
|
||||
);
|
||||
const summary = document.createElement("p");
|
||||
summary.className = "b05-drainage__facility-note";
|
||||
let designFlowM3s: number | null = null;
|
||||
/** 현재 조건(설계유량·월류 폭)의 필요 최소 수심(m). 계산 불가면 null. */
|
||||
let minDepthM: number | null = null;
|
||||
|
||||
const suggestedWidth = (kind: FordKind): number =>
|
||||
kind === "ford_bridge" ? FORD_BRIDGE_DEFAULT_WIDTH_M : FORD_PAVEMENT_DEFAULT_WIDTH_M;
|
||||
|
||||
function sync(): void {
|
||||
const widthM = Number.parseFloat(width.value || width.dataset.suggested || "");
|
||||
const section = designFlowM3s !== null ? fordSection(designFlowM3s, widthM) : null;
|
||||
minDepthM = section ? Number(section.depthM.toFixed(2)) : null;
|
||||
height.placeholder = minDepthM !== null ? `제안 필요 수심 ${minDepthM.toFixed(2)}` : "";
|
||||
if (designFlowM3s === null) {
|
||||
summary.textContent =
|
||||
"담당 유역의 설계유량이 아직 없습니다 — [유역 분석] 후 개략 단면이 나옵니다.";
|
||||
return;
|
||||
}
|
||||
const head = `설계유량 ${designFlowM3s.toFixed(3)} ㎥/s`;
|
||||
if (!section) {
|
||||
summary.textContent = `${head} · 월류 폭을 넣으면 필요 수심·단면을 계산합니다.`;
|
||||
return;
|
||||
}
|
||||
// 필요 최소 수심이다 — 월류 높이는 이 값 아래로 내릴 수 없다.
|
||||
summary.textContent =
|
||||
`${head} · 필요 수심 ${section.depthM.toFixed(2)} m · ` +
|
||||
`필요 단면 ${section.areaM2.toFixed(2)} ㎡ · 유속 ${section.velocityMs.toFixed(2)} m/s — ` +
|
||||
`월류 높이는 필요 수심 이상만 입력됩니다.`;
|
||||
}
|
||||
|
||||
// 적은 값이 계산값 미만이면 받지 않는다 — 계산값으로 되돌리고 잠깐 붉힌다(빈 칸은 그대로 빈 칸).
|
||||
height.addEventListener("change", () => {
|
||||
const value = Number.parseFloat(height.value);
|
||||
if (
|
||||
minDepthM !== null &&
|
||||
height.value !== "" &&
|
||||
(!Number.isFinite(value) || value < minDepthM)
|
||||
) {
|
||||
height.value = minDepthM.toFixed(2);
|
||||
height.classList.add("is-invalid");
|
||||
window.setTimeout(() => height.classList.remove("is-invalid"), 900);
|
||||
}
|
||||
emit();
|
||||
});
|
||||
|
||||
return {
|
||||
widthRow,
|
||||
slopeRow,
|
||||
countRow,
|
||||
summary,
|
||||
inputs: [count, width],
|
||||
setDesignFlow(designFlow) {
|
||||
designFlowM3s = designFlow ?? null;
|
||||
},
|
||||
sync,
|
||||
write(kind, options) {
|
||||
const text = (key: string): string =>
|
||||
options[key] !== undefined ? String(options[key]) : "";
|
||||
count.value = kind === "ford_bridge" ? text("pipe_count") : "";
|
||||
width.value = text("ford_width_m");
|
||||
width.dataset.suggested = String(suggestedWidth(kind));
|
||||
width.placeholder = `제안 ${suggestedWidth(kind)}`;
|
||||
height.value = text("ford_height_m");
|
||||
slope.value = text("ford_slope_pct");
|
||||
thickness.value = text("thickness_cm");
|
||||
length.value = text("length_m");
|
||||
},
|
||||
read(kind, target) {
|
||||
putNumber(target, "ford_width_m", width.value);
|
||||
// 월류 높이는 cm 단위 수심이라 0.01m 정밀도로 싣는다(putNumber는 0.1m 반올림).
|
||||
const value = Number.parseFloat(height.value);
|
||||
if (Number.isFinite(value) && value > 0) target.ford_height_m = Number(value.toFixed(2));
|
||||
if (kind === "ford_pavement") {
|
||||
putNumber(target, "ford_slope_pct", slope.value);
|
||||
putNumber(target, "thickness_cm", thickness.value);
|
||||
putNumber(target, "length_m", length.value);
|
||||
return;
|
||||
}
|
||||
const pipes = Number.parseInt(count.value, 10);
|
||||
if (Number.isFinite(pipes) && pipes > 0) target.pipe_count = pipes;
|
||||
},
|
||||
fillSuggested(kind) {
|
||||
if (!width.value) width.value = String(suggestedWidth(kind));
|
||||
sync();
|
||||
if (!height.value && minDepthM !== null) height.value = minDepthM.toFixed(2);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Drainage_Facility_Merge.ts
|
||||
* 계곡 통과 시설 [저장] — **폼이 아는 칸만 갈아 끼우고, 폼이 모르는 칸은 건드리지 않음**
|
||||
* (2026-09-14 브레인 판정).
|
||||
*
|
||||
* ⚠ 앞서 저장이 옵션을 **통째로** 갈아 끼웠음 — 구조물 집계표·구조물도로 적은 칸(관 기슭막이
|
||||
* 기초 `revet_foundation` · 독립 기슭막이 `foundation`·뒷길이·돌 종류 …)이 B05 에서 그 시설을
|
||||
* 한 번 더 저장하면 **조용히 지워졌음**. 「작업본 쓰기는 [저장]에서만」인데 그 [저장]이 옆 칸까지
|
||||
* 지우던 자리.
|
||||
* ⚠ 통째로 갈아 끼우는 것은 **시설 종류 자체를 바꿀 때만** — 그때는 옛 칸이 뜻을 잃음.
|
||||
* ⚠ 폼이 아는 칸을 폼이 비워 보내면(값을 지움·유입구를 집수정 → 기슭막이로 바꿔 집수정 칸이 빠짐)
|
||||
* 그 칸은 지워짐 — 사용자가 폼에서 한 일이므로 맞음.
|
||||
* ⚠ 폼에 칸을 늘리면 아래 목록에도 넣을 것. 빠뜨리면 그 칸을 **폼에서 지워도 옛 값이 남는**
|
||||
* 쪽으로 틀림(지워지는 쪽보다 덜 위험). 거울 시험 `test_b05_facility_options_merge`.
|
||||
* ⚠ import 없음 — 시험이 이 파일만 옮겨 돌림.
|
||||
* ========================================================================== */
|
||||
|
||||
const revetKeys = (side: "inlet" | "outlet"): string[] =>
|
||||
["form", "length_m", "height_m", "before_m", "after_m"].map((key) => `${side}_revet_${key}`);
|
||||
const wingKeys = (side: "in" | "out"): string[] => [
|
||||
`wing_${side}`,
|
||||
`wing_${side}_height_m`,
|
||||
`wing_${side}_length_m`,
|
||||
`wing_${side}_angle_deg`,
|
||||
];
|
||||
|
||||
/** 시설 종류별로 **폼이 적는 칸** — `createFacilityOptionsForm().readOptions()` 가 쓰는 키 전부. */
|
||||
export const FORM_OPTION_KEYS: Readonly<Record<string, readonly string[]>> = {
|
||||
pipe: [
|
||||
"pipe_diameter_mm",
|
||||
"pipe_kind",
|
||||
"wing_wall_type",
|
||||
"inlet_type",
|
||||
"inlet_structure",
|
||||
"inlet_basin_form",
|
||||
"inlet_basin_material",
|
||||
"inlet_basin_length_m",
|
||||
...revetKeys("inlet"),
|
||||
"outlet_type",
|
||||
...revetKeys("outlet"),
|
||||
],
|
||||
box_culvert: ["body_width_m", "body_height_m", ...wingKeys("in"), ...wingKeys("out")],
|
||||
ford_pavement: ["ford_width_m", "ford_height_m", "ford_slope_pct", "thickness_cm", "length_m"],
|
||||
ford_bridge: [
|
||||
"pipe_kind",
|
||||
"pipe_diameter_mm",
|
||||
"pipe_count",
|
||||
"ford_width_m",
|
||||
"ford_height_m",
|
||||
...wingKeys("in"),
|
||||
...wingKeys("out"),
|
||||
],
|
||||
revetment: ["side", ...revetKeys("inlet"), ...revetKeys("outlet")],
|
||||
};
|
||||
|
||||
interface MergeableAttributes {
|
||||
facility: string;
|
||||
start_m?: number;
|
||||
end_m?: number;
|
||||
options?: Record<string, string | number>;
|
||||
}
|
||||
|
||||
/** 저장할 시설 정보 — 같은 종류면 폼이 모르는 옛 칸을 이어 붙임. */
|
||||
export function mergeFacilityOptions<T extends MergeableAttributes>(
|
||||
previous: T | null,
|
||||
next: T,
|
||||
): T {
|
||||
if (!previous || previous.facility !== next.facility) return next;
|
||||
const managed = new Set(FORM_OPTION_KEYS[next.facility] ?? []);
|
||||
const kept = Object.entries(previous.options ?? {}).filter(([key]) => !managed.has(key));
|
||||
const options = { ...Object.fromEntries(kept), ...(next.options ?? {}) };
|
||||
return { ...next, options: Object.keys(options).length ? options : undefined };
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import { buildStrengthArray } from "../B04_PreProcess/B04_PreProcess_UI_FlowRamp
|
||||
import { resampleRoute } from "../B04_PreProcess/B04_PreProcess_UI_RouteSamples";
|
||||
import { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes";
|
||||
import { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility";
|
||||
import { mergeFacilityOptions } from "./B05_Profile_UI_Drainage_Facility_Merge";
|
||||
import { writePendingPipes } from "./B05_Profile_Api_Pipes_Draft";
|
||||
import { createDrainageChrome } from "./B05_Profile_UI_Drainage_Chrome";
|
||||
import { bindDrainageInteractions } from "./B05_Profile_UI_Drainage_Interact";
|
||||
@@ -607,8 +608,10 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
pipeEditor.addAtChainage(chainageM);
|
||||
},
|
||||
updatePipeFacility(fromChainageM, toChainageM, attributes) {
|
||||
// ⚠ 통째로 갈아 끼우지 않음 — 폼이 모르는 칸(집계표로 적은 기초 등)을 지키려 이어 붙임.
|
||||
const previous = facilityStore.get(fromChainageM);
|
||||
facilityStore.set(fromChainageM, null);
|
||||
facilityStore.set(toChainageM, attributes);
|
||||
facilityStore.set(toChainageM, mergeFacilityOptions(previous, attributes));
|
||||
if (Math.abs(fromChainageM - toChainageM) > 0.005) {
|
||||
// 기준점이 옮겨졌다 — 관을 이동시키면 onCommit이 재계산을 돌리고,
|
||||
// attach가 새 위치로 시설 정보를 승계한다.
|
||||
|
||||
@@ -9,12 +9,23 @@
|
||||
|
||||
import { chainageToStation, stationToChainage } from "./B05_Profile_Util_Station";
|
||||
|
||||
export function field(labelText: string, input: HTMLElement): HTMLLabelElement {
|
||||
export function field(
|
||||
labelText: string,
|
||||
input: HTMLElement,
|
||||
basis?: string | null,
|
||||
): HTMLLabelElement {
|
||||
const wrapper = document.createElement("label");
|
||||
wrapper.className = "b05-route__field";
|
||||
const caption = document.createElement("span");
|
||||
caption.textContent = labelText;
|
||||
wrapper.append(caption, input);
|
||||
// 칸 밑 근거 한 줄 — 등록부 `default_basis`(기본값의 뜻 · 10-A 2026-09-14 사용자 확정).
|
||||
if (basis) {
|
||||
const note = document.createElement("small");
|
||||
note.className = "b05-structure__basis";
|
||||
note.textContent = basis;
|
||||
wrapper.append(note);
|
||||
}
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
@@ -32,6 +43,88 @@ 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;
|
||||
/** 기본값의 뜻 — 칸 밑 근거 한 줄. */
|
||||
default_basis?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 등록부 옵션 입력 한 칸 — **저장된 값만 칸에 넣고, 등록부 기본값은 제안으로만** 보임
|
||||
* (숫자·글 칸은 회색 글씨, 고르기 칸은 빈 보기 이름). 2026-09-14 브레인 판정 「기본값을 몰래
|
||||
* 확정으로 바꾸지 않는다」 — 칸에 채워 두면 저장이 그 값을 적어 사용자가 한 일 없이 확정이 됨.
|
||||
* 고르기 칸은 늘 빈 보기를 둠 — 첫 보기를 미리 고르지 않음.
|
||||
*/
|
||||
export function optionControl(
|
||||
option: OptionShape,
|
||||
stored: string | number | undefined,
|
||||
): HTMLInputElement | HTMLSelectElement {
|
||||
const value = stored === undefined || stored === null ? "" : String(stored);
|
||||
const suggested = option.default === null || option.default === "" ? "" : String(option.default);
|
||||
// 툴팁 — 표가 아직 안 읽는 칸이면 그 사유가 먼저(넣어도 안 바뀜을 알아야 함), 아니면 비울 때의 뜻.
|
||||
const tip = option.not_in_table ?? option.empty_means ?? "";
|
||||
if (option.input === "select") {
|
||||
const blank = suggested
|
||||
? `— 안 정함 (제안 ${suggested}) —`
|
||||
: option.required
|
||||
? "— 선택 —"
|
||||
: "— 안 정함 —";
|
||||
const element = select([["", blank], ...option.choices.map((c) => [c, c] as [string, string])]);
|
||||
element.value = value;
|
||||
if (tip) element.title = tip;
|
||||
return element;
|
||||
}
|
||||
const element =
|
||||
option.input === "number" ? numberInput("0.1", "0") : document.createElement("input");
|
||||
if (option.input !== "number") element.type = "text";
|
||||
element.value = value;
|
||||
element.placeholder = suggested ? `제안 ${suggested}` : option.required ? "필수 입력" : "";
|
||||
if (tip) element.title = tip;
|
||||
const limit = option.warn_above;
|
||||
if (typeof limit === "number") {
|
||||
// 기준을 넘으면 알리기만 — 막지 않음(2026-09-14 브레인 판정 ①·㉰).
|
||||
const warn = (): void => {
|
||||
const over = element.value.trim() !== "" && Number(element.value) > limit;
|
||||
element.classList.toggle("is-outside-standard", over);
|
||||
element.title = over ? (option.warn_message ?? "") : tip;
|
||||
};
|
||||
element.addEventListener("input", warn);
|
||||
warn();
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
/** [제안값 넣기] — **누른 때만** 빈 칸에 등록부 기본값을 넣음(적은 칸은 안 건드림) · 층따기 단추와 같은 모양. */
|
||||
export function suggestButton(
|
||||
entries: ReadonlyArray<{ input: HTMLInputElement | HTMLSelectElement; suggested: string }>,
|
||||
): HTMLButtonElement {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "b05-structure__suggest";
|
||||
button.textContent = "제안값 넣기";
|
||||
button.addEventListener("click", () => {
|
||||
const filled = entries.filter((entry) => entry.suggested && !entry.input.value);
|
||||
filled.forEach((entry) => {
|
||||
entry.input.value = entry.suggested;
|
||||
entry.input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
// 반영은 한 번만 — 칸마다 저장 흐름이 돌지 않게 마지막 칸만 change 를 울림.
|
||||
filled.at(-1)?.input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
return button;
|
||||
}
|
||||
|
||||
/** 측점번호 + 잔여거리 두 칸 묶음. */
|
||||
export interface StationFields {
|
||||
wrap: HTMLElement;
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
defaultOptions,
|
||||
isB05Option,
|
||||
structureAnchorM,
|
||||
type StructureInstance,
|
||||
@@ -24,7 +23,7 @@ import {
|
||||
} from "./B05_Profile_Api_Structures";
|
||||
import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import { type FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility";
|
||||
import { field, numberInput, select } from "./B05_Profile_UI_Structures_Fields";
|
||||
import { field, optionControl, suggestButton } from "./B05_Profile_UI_Structures_Fields";
|
||||
import { buildStructuresForm } from "./B05_Profile_UI_Structures_Form";
|
||||
import {
|
||||
bindStructuresEvents,
|
||||
@@ -316,42 +315,24 @@ 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");
|
||||
input.title = "지금은 고를 수 없는 항목입니다.";
|
||||
}
|
||||
optionRow.append(field(label, input));
|
||||
optionRow.append(field(label, input, option.default_basis));
|
||||
input.addEventListener("change", () => liveCommit());
|
||||
optionInputs.push({
|
||||
key: option.key,
|
||||
@@ -390,6 +371,9 @@ export function createStructuresSection(
|
||||
input.addEventListener("change", () => refresh(true));
|
||||
}
|
||||
});
|
||||
if (suggestions.some((entry) => entry.suggested)) {
|
||||
optionRow.append(suggestButton(suggestions));
|
||||
}
|
||||
syncOptionLock();
|
||||
syncRangeDisplay();
|
||||
}
|
||||
@@ -677,10 +661,11 @@ export function createStructuresSection(
|
||||
endFields.write(isInterval ? chainageM + DEFAULT_INTERVAL_LENGTH_M : null, step);
|
||||
memoField.value = "";
|
||||
syncPlacementFields();
|
||||
renderOptionFields(type.managed_by ? undefined : defaultOptions(type));
|
||||
// BOX암거는 레지스트리 기본값(본체 2.0×2.0·날개벽 있음 1m/2m/45°)을 실어 폼을
|
||||
// 연다 — [추가]만 눌러도 하류가 제원을 받는다(2026-08-17 사용자 확정 유지).
|
||||
syncFacilityForm(typeId === "box_culvert" ? defaultOptions(type) : {});
|
||||
// 기본값을 값으로 안 채움 — 칸은 비우고 제안으로만(브레인 판정 ②).
|
||||
renderOptionFields();
|
||||
// BOX암거도 기본값을 안 실음 — 제안으로만(판정 ③ · 옛 「[추가]만 눌러도 제원」 걷음).
|
||||
// 비운 칸은 하류(횡단도)가 등록부 기본값으로 그림 — 저장만 안 함.
|
||||
syncFacilityForm({});
|
||||
// 임시 배치도 즉시 진행(A군).
|
||||
if (type.managed_by) commitTempPipe();
|
||||
syncButtons();
|
||||
|
||||
@@ -231,6 +231,8 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis
|
||||
ctx.optionInputs.find((entry) => entry.key === "length_m")?.input.focus();
|
||||
return;
|
||||
}
|
||||
// 높이는 비워도 놓음 — 자리부터 잡고 치수를 뒤에 넣는 길을 막지 않음. 높이가 물량 밑수인
|
||||
// 벽은 B08 에서 줄만 서고 미확정(금액 밖)으로 뜸(2026-09-14 브레인 판정 ①, `missing_height_reason`).
|
||||
const before = ctx.readBeforeM();
|
||||
if (anchor - before < -0.005) {
|
||||
const beforeInput = ctx.optionInputs.find((entry) => entry.key === "before_m")?.input;
|
||||
|
||||
@@ -214,6 +214,18 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 칸 밑 근거 한 줄 — 등록부 `default_basis`(10-A). 회색 작은 글씨. */
|
||||
.b05-structure__basis {
|
||||
color: var(--color-text-muted, #9aa1ad);
|
||||
font-size: var(--text-caption, 12px);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* 근거 줄로 옆 칸이 길어져도 [제안값 넣기]가 세로로 늘지 않게. */
|
||||
.b05-structure__suggest {
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
/* 시작·기준·종료 측점 = 3행. 한 행은 [라벨][측점][+거리] 가로 배치
|
||||
* (2026-08-17 사용자 지시 2). */
|
||||
.b05-structure__position-row {
|
||||
|
||||
@@ -207,8 +207,12 @@ export interface CulvertSideSpec {
|
||||
/** 집수정 기준측점 전/후 몫(m) — 기슭막이와 같은 체계(2026-08-24). 기본 각 1m. */
|
||||
basin_before_m?: number | null;
|
||||
basin_after_m?: number | null;
|
||||
/** 기슭막이 전면 기울기(1:n). 돌쌓기 전면 1:0.3(교본 7-3). */
|
||||
/** 기슭막이 전면 기울기(1:n) 종전값 — 그림은 `revetWallSpec().lean`(표준경사 표)을 씀. */
|
||||
face_slope?: number;
|
||||
/** 독립 기슭막이 기울기 판정 칸(저장 원본) — 설치 측 · 성토/절토 · 전면 기울기(B08 과 같은 칸). */
|
||||
face_side?: string | null;
|
||||
face_role?: string | null;
|
||||
face_slope_ratio?: number | string | null;
|
||||
/** 보호공(물받이) 길이 = 낙차고 × 2 (사방교본 교차 참조, 2026-08-19 사용자 확정). */
|
||||
apron_length_m?: number;
|
||||
/** 보호공 두께 1.0m 내외 (사방교본 교차 참조). */
|
||||
@@ -265,6 +269,8 @@ export interface FordSet {
|
||||
min_cover_m: number;
|
||||
wing_in: FordWingSpec;
|
||||
wing_out: FordWingSpec;
|
||||
/** 정본에 안 적혀 기본값으로 그린 칸 「이름 값」 — 카드 머리에 「기본값(미확정)」. */
|
||||
defaulted?: string[];
|
||||
}
|
||||
|
||||
/** BOX암거 측점의 세트 제원 — 상판·내공(유로)·저판 + 날개벽 투영 연장. */
|
||||
@@ -283,6 +289,8 @@ export interface BoxSet {
|
||||
span_m: number;
|
||||
wing_in: FordWingSpec;
|
||||
wing_out: FordWingSpec;
|
||||
/** 정본에 안 적혀 기본값으로 그린 칸 「이름 값」 — 카드 머리에 「기본값(미확정)」. */
|
||||
defaulted?: string[];
|
||||
}
|
||||
|
||||
export interface CrossSection extends SectionStation {
|
||||
|
||||
@@ -74,6 +74,26 @@ APRON_THICKNESS_M = 0.45
|
||||
# 1:0.3~0.5의 급한 쪽. 지식DB 근거가 있는 값이라 교차 참조 표시가 붙지 않는다.
|
||||
REVET_FACE_SLOPE = 0.3
|
||||
|
||||
# ── 관 벽 최소 높이 — ⚠ TS 짝: `B06_Section_UI_Cross_Culvert_Const.ts`
|
||||
# (`REVET_FREEBOARD_M`·`REVET_HEIGHT_STEP_M`·`REVET_EMBED_DEPTH_M`·`pipeWallMinPureHeight`).
|
||||
# 횡단도가 관 벽을 그리는 그 높이 — 구조물 표·구조물도 그림도 이 값을 씀(2026-09-14 브레인 판정
|
||||
# 「그림 둘이 같은 벽을 다르게 그리면 결함 · 근거 있는 관경 기준으로 맞춤」). 거울 시험
|
||||
# `test_b06_pipe_wall_height_mirror`.
|
||||
REVET_FREEBOARD_M = 0.5 # 관 상단 ~ 벽 상단 여유고(잠정 · 토피 0.5 와 같은 값)
|
||||
REVET_HEIGHT_STEP_M = 0.1 # 높이 눈금
|
||||
REVET_EMBED_DEPTH_M = 0.5 # 근입 깊이
|
||||
|
||||
|
||||
def pipe_wall_min_height_m(diameter_m: float) -> float:
|
||||
"""관 벽 **순수 높이**(근입 포함) 최소값 — 관경 + 여유고를 0.1m 로 올린 뒤 근입을 더함.
|
||||
|
||||
⚠ 「최소」다 — 실제 설계값은 더 클 수 있어 이 값으로 선 수량은 미확정으로 둠(금액 밖).
|
||||
"""
|
||||
raw = float(diameter_m) + REVET_FREEBOARD_M
|
||||
steps = math.ceil(raw / REVET_HEIGHT_STEP_M - 1e-9)
|
||||
return round(steps * REVET_HEIGHT_STEP_M + REVET_EMBED_DEPTH_M, 3)
|
||||
|
||||
|
||||
# (2026-08-20 사용자 확정) 배관은 **수평도 가능** — 그림에서 경사를 강제로 만들지
|
||||
# 않는다. config `DRAINAGE_PIPE_SLOPE_DEG`(10도)는 유효직경 수리계산 전용으로 남는다.
|
||||
|
||||
@@ -139,7 +159,9 @@ def _number(value: Any, fallback: float | None) -> float | None:
|
||||
return fallback
|
||||
|
||||
|
||||
def _side_spec(options: dict[str, Any], defaults: dict[str, Any], side: str) -> dict[str, Any]:
|
||||
def _side_spec(
|
||||
options: dict[str, Any], defaults: dict[str, Any], side: str, diameter_m: float = 1.0
|
||||
) -> dict[str, Any]:
|
||||
"""유입("inlet")·유출("outlet") 한쪽의 부속 제원.
|
||||
|
||||
구조가 집수정이면 기슭막이·보호공을 만들지 않는다 — 집수정 단면은 후속 작업이라
|
||||
@@ -164,9 +186,10 @@ def _side_spec(options: dict[str, Any], defaults: dict[str, Any], side: str) ->
|
||||
)
|
||||
return spec
|
||||
|
||||
# 높이를 안 적었으면 **횡단도가 그리는 관경 기준 최소 높이**(2026-09-14 · 옛 기본 2.5 걷음).
|
||||
height = _number(
|
||||
options.get(f"{side}_revet_height_m"),
|
||||
_number(defaults.get(f"{side}_revet_height_m"), None),
|
||||
_number(defaults.get(f"{side}_revet_height_m"), pipe_wall_min_height_m(diameter_m)),
|
||||
)
|
||||
length = _number(
|
||||
options.get(f"{side}_revet_length_m"),
|
||||
@@ -208,10 +231,11 @@ def _culvert_set(options: dict[str, Any] | None) -> dict[str, Any]:
|
||||
values.get("pipe_diameter_mm"), _number(defaults.get("pipe_diameter_mm"), 1000.0)
|
||||
)
|
||||
kind = values.get("pipe_kind") or defaults.get("pipe_kind")
|
||||
diameter_m = round((diameter_mm or 1000.0) / 1000.0, 3)
|
||||
return {
|
||||
"type": "pipe",
|
||||
"pipe_kind": str(kind) if kind else None,
|
||||
"diameter_m": round((diameter_mm or 1000.0) / 1000.0, 3),
|
||||
"diameter_m": diameter_m,
|
||||
"min_cover_m": MIN_PIPE_COVER_M,
|
||||
# 관 기슭막이 기초 축 — **유입·유출 한 칸으로** 받는다(2026-09-09). 한쪽만 기초유로
|
||||
# 하는 일이 드물어 폼을 둘로 늘리지 않았다. 실무가 갈라 쓰면 그때 나눌 것.
|
||||
@@ -219,8 +243,8 @@ def _culvert_set(options: dict[str, Any] | None) -> dict[str, Any]:
|
||||
str(values.get("revet_foundation") or defaults.get("revet_foundation") or "").strip()
|
||||
or None
|
||||
),
|
||||
"inlet": _side_spec(values, defaults, "inlet"),
|
||||
"outlet": _side_spec(values, defaults, "outlet"),
|
||||
"inlet": _side_spec(values, defaults, "inlet", diameter_m),
|
||||
"outlet": _side_spec(values, defaults, "outlet", diameter_m),
|
||||
}
|
||||
|
||||
|
||||
@@ -249,6 +273,11 @@ def _revet_side(values: dict[str, Any], role: str) -> dict[str, Any]:
|
||||
values.get(f"{role}_revet_after_m"), _number(values.get("after_m"), None)
|
||||
),
|
||||
"face_slope": REVET_FACE_SLOPE,
|
||||
# 전면 기울기를 B08 과 같은 칸으로 가르는 값 — 저장 원본 그대로(2026-09-14 B5 · TS `revetSide`).
|
||||
# 설치 측은 `side` 기본값(양쪽)을 붙이기 **전** 값이라야 B08(빈 칸 = 자동)과 같게 갈림.
|
||||
"face_side": values.get("side"),
|
||||
"face_role": values.get("face_role"),
|
||||
"face_slope_ratio": values.get("face_slope_ratio"),
|
||||
}
|
||||
if height is not None and height > 0:
|
||||
spec["apron_length_m"] = round(height * APRON_LENGTH_FACTOR, 3)
|
||||
@@ -308,6 +337,36 @@ def _wing_spec(values: dict[str, Any], defaults: dict[str, Any], side: str) -> d
|
||||
}
|
||||
|
||||
|
||||
def _defaulted(values: dict[str, Any], fields: list[tuple[str, str, Any]]) -> list[str]:
|
||||
"""정본에 안 적혀 **기본값으로 그린 칸** 「이름 값」 — 횡단도가 「기본값(미확정)」으로 적음.
|
||||
|
||||
2026-09-14 브레인 판정 ② — 저장만 안 하는 것으로는 모자람 · 그림이 설계값처럼 보이면 같은 병.
|
||||
TS 짝 `defaulted`(`common_util_culvert_sets.ts`).
|
||||
"""
|
||||
found = []
|
||||
for key, label, used in fields:
|
||||
if used is None or values.get(key) not in (None, ""):
|
||||
continue
|
||||
found.append(f"{label} {used:g}" if isinstance(used, float) else f"{label} {used}")
|
||||
return found
|
||||
|
||||
|
||||
def _wing_defaulted(values: dict[str, Any], wing: dict[str, Any], side: str) -> list[str]:
|
||||
"""날개벽 한쪽의 기본값 칸 — 안 세운 날개벽은 치수를 안 봄."""
|
||||
name = "유입" if side == "in" else "유출"
|
||||
prefix = f"wing_{side}"
|
||||
fields: list[tuple[str, str, Any]] = [
|
||||
(prefix, f"{name} 날개벽", "있음" if wing["installed"] else "없음")
|
||||
]
|
||||
if wing["installed"]:
|
||||
fields += [
|
||||
(f"{prefix}_height_m", f"{name} 날개벽 높이", wing["height_m"]),
|
||||
(f"{prefix}_length_m", f"{name} 날개벽 길이", wing["length_m"]),
|
||||
(f"{prefix}_angle_deg", f"{name} 날개벽 각도", wing["angle_deg"]),
|
||||
]
|
||||
return _defaulted(values, fields)
|
||||
|
||||
|
||||
def _ford_set(options: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""세월교 1개소의 세트 제원(관 + 양측 측벽 + 바닥판 + 날개벽 연장).
|
||||
|
||||
@@ -323,12 +382,26 @@ def _ford_set(options: dict[str, Any] | None) -> dict[str, Any]:
|
||||
width = _number(values.get("ford_width_m"), _number(defaults.get("ford_width_m"), None))
|
||||
count = _number(values.get("pipe_count"), _number(defaults.get("pipe_count"), None))
|
||||
depth = _number(values.get("ford_height_m"), None)
|
||||
span = width if width and width > 0 else FORD_DEFAULT_WIDTH_M
|
||||
pipe_count = max(int(count), 1) if count else 1
|
||||
wing_in = _wing_spec(values, defaults, "in")
|
||||
wing_out = _wing_spec(values, defaults, "out")
|
||||
defaulted = _defaulted(
|
||||
values,
|
||||
[
|
||||
("pipe_kind", "관종", str(kind) if kind else None),
|
||||
("pipe_diameter_mm", "관경", diameter_mm),
|
||||
("ford_width_m", "월류 폭", span),
|
||||
("pipe_count", "배관 수량", pipe_count),
|
||||
("ford_height_m", "월류 높이", 0.0),
|
||||
],
|
||||
)
|
||||
return {
|
||||
"type": "ford",
|
||||
"pipe_kind": str(kind) if kind else None,
|
||||
"diameter_m": round((diameter_mm or 1000.0) / 1000.0, 3),
|
||||
"pipe_count": max(int(count), 1) if count else 1,
|
||||
"span_m": width if width and width > 0 else FORD_DEFAULT_WIDTH_M,
|
||||
"pipe_count": pipe_count,
|
||||
"span_m": span,
|
||||
# 월류 높이 — 구체 위 노면은 이만큼 낮게 앉는다(단면은 월류부 가장 아래를 자른
|
||||
# 자리다). 계획고를 통째로 내려 측벽·바닥판·절성토 면적이 함께 따라간다
|
||||
# (2026-08-30 사용자 확정). 값이 없으면 0 = 내리지 않는다.
|
||||
@@ -336,8 +409,11 @@ def _ford_set(options: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"slab_thickness_m": FORD_SLAB_THICKNESS_M,
|
||||
"wall_thickness_m": FORD_WALL_THICKNESS_M,
|
||||
"min_cover_m": MIN_PIPE_COVER_M,
|
||||
"wing_in": _wing_spec(values, defaults, "in"),
|
||||
"wing_out": _wing_spec(values, defaults, "out"),
|
||||
"wing_in": wing_in,
|
||||
"wing_out": wing_out,
|
||||
"defaulted": defaulted
|
||||
+ _wing_defaulted(values, wing_in, "in")
|
||||
+ _wing_defaulted(values, wing_out, "out"),
|
||||
}
|
||||
|
||||
|
||||
@@ -353,12 +429,14 @@ def _ford_pavement_set(options: dict[str, Any] | None) -> dict[str, Any]:
|
||||
width = _number(values.get("ford_width_m"), _number(defaults.get("ford_width_m"), None))
|
||||
depth = _number(values.get("ford_height_m"), None)
|
||||
slope = _number(values.get("ford_slope_pct"), None)
|
||||
span = width if width and width > 0 else FORD_PAVEMENT_DEFAULT_WIDTH_M
|
||||
return {
|
||||
"type": "ford_pavement",
|
||||
"span_m": width if width and width > 0 else FORD_PAVEMENT_DEFAULT_WIDTH_M,
|
||||
"span_m": span,
|
||||
# 노선 중심에서 잰 깊이. 없으면 화면이 파임을 그리지 않는다(수치를 지어내지 않는다).
|
||||
"depth_m": depth if depth and depth > 0 else None,
|
||||
"slope_pct": slope,
|
||||
"defaulted": _defaulted(values, [("ford_width_m", "월류 폭", span)]),
|
||||
}
|
||||
|
||||
|
||||
@@ -375,6 +453,8 @@ def _box_set(options: dict[str, Any] | None) -> dict[str, Any]:
|
||||
inner_height = _number(values.get("body_height_m"), _number(defaults.get("body_height_m"), 2.0))
|
||||
wall = FORD_WALL_THICKNESS_M
|
||||
slab = FORD_SLAB_THICKNESS_M
|
||||
wing_in = _wing_spec(values, defaults, "in")
|
||||
wing_out = _wing_spec(values, defaults, "out")
|
||||
return {
|
||||
"type": "box",
|
||||
"inner_width_m": inner_width or 2.0,
|
||||
@@ -385,8 +465,17 @@ def _box_set(options: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"cover_m": BOX_COVER_M,
|
||||
# 도로 진행 방향 길이 = 내공 폭 + 측벽 두 장. 이 폭만큼 측점에 걸친다.
|
||||
"span_m": (inner_width or 2.0) + 2 * wall,
|
||||
"wing_in": _wing_spec(values, defaults, "in"),
|
||||
"wing_out": _wing_spec(values, defaults, "out"),
|
||||
"wing_in": wing_in,
|
||||
"wing_out": wing_out,
|
||||
"defaulted": _defaulted(
|
||||
values,
|
||||
[
|
||||
("body_width_m", "본체 폭", inner_width or 2.0),
|
||||
("body_height_m", "본체 높이", inner_height or 2.0),
|
||||
],
|
||||
)
|
||||
+ _wing_defaulted(values, wing_in, "in")
|
||||
+ _wing_defaulted(values, wing_out, "out"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"""미교차 측점의 지반 샘플을 **지표면 끝까지** 넓힘 — ㉳ (가) (2026-09-14 브레인 판정).
|
||||
|
||||
반폭(기본 20m) 샘플 끝에서 사면이 원지반과 안 만나면 면적이 거기서 잘려 성토가 **작게** 섰다
|
||||
(936be972 미교차 12곳 중 11곳이 넓히면 닫힘 · 성토 +2,559.9㎥). ⇒ **열린 쪽만** +5m 씩 확정 지표면에서
|
||||
다시 떠 계산하고, 닫히면 멈춘다. 새 걸음에 지표면 밖(무효) 샘플이 섞이면 그 걸음은 안 붙이고 멈춘다 —
|
||||
그 측점은 「지표면 끝까지 넓혀도 안 만남」으로 미교차가 남는다.
|
||||
⚠ 상한 숫자를 두지 않는다 — 지표면이 실제로 있는 끝이라 근거가 필요 없음(인위 기본값 금지).
|
||||
⚠ 716 판정 「미교차 측점만 반폭 +5m 한 번」을 갈음(그것으론 4곳만 닫혔음).
|
||||
⚠ 계산식은 안 바뀐다 — 샘플(입력)만 넓어진다. 넓힌 샘플은 횡단 파일에 남겨 화면(TS)·Node·B08 이
|
||||
같은 지반을 본다(한 벌).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Sections import cross_filename
|
||||
from B06_Section.B06_Section_Engine_Design import (
|
||||
_SLOPE_CLOSE_TOLERANCE_M,
|
||||
compute_cross_design,
|
||||
curve_widening_args,
|
||||
)
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: 한 걸음 — 브레인 판정 「+5m 씩」.
|
||||
EXTEND_STEP_M = 5.0
|
||||
#: 지표면 끝까지 넓혀도 안 닫힌 측점의 사유(경고가 그대로 씀).
|
||||
SURFACE_END_REASON = "지표면 끝까지 넓혀도 성토 비탈이 원지반과 안 만남"
|
||||
|
||||
|
||||
def _valid_sorted(samples: list[dict[str, Any]]) -> list[tuple[float, float]]:
|
||||
return sorted(
|
||||
(float(s["offset_m"]), float(s["elevation_m"]))
|
||||
for s in samples
|
||||
if s.get("valid") is not False and s.get("elevation_m") is not None
|
||||
)
|
||||
|
||||
|
||||
def _open_ends(samples: list[dict[str, Any]], design: dict[str, Any]) -> tuple[bool, bool]:
|
||||
"""(우 끝 열림, 좌 끝 열림) — 샘플 끝의 지반과 설계선 높이차가 허용오차를 넘나."""
|
||||
ground = _valid_sorted(samples)
|
||||
line = sorted(
|
||||
(float(p["offset_m"]), float(p["elevation_m"])) for p in design.get("design_line") or []
|
||||
)
|
||||
if not ground or not line:
|
||||
return False, False
|
||||
right = abs(ground[0][1] - line[0][1]) > _SLOPE_CLOSE_TOLERANCE_M
|
||||
left = abs(ground[-1][1] - line[-1][1]) > _SLOPE_CLOSE_TOLERANCE_M
|
||||
return right, left
|
||||
|
||||
|
||||
def extend_unclosed(
|
||||
samples: list[dict[str, Any]],
|
||||
frame: dict[str, Any],
|
||||
compute: Callable[[list[dict[str, Any]]], dict[str, Any]],
|
||||
sampler: Any,
|
||||
step_m: float = EXTEND_STEP_M,
|
||||
) -> tuple[list[dict[str, Any]], dict[str, Any]] | None:
|
||||
"""넓힌 샘플과 `{left_m, right_m, surface_end}` — 이미 닫혔으면 `None`."""
|
||||
design = compute(samples)
|
||||
if not design.get("slope_unclosed"):
|
||||
return None
|
||||
offsets = [offset for offset, _z in _valid_sorted(samples)]
|
||||
spacing = min(b - a for a, b in zip(offsets, offsets[1:]) if b - a > 1e-9)
|
||||
count = max(int(round(step_m / spacing)), 1)
|
||||
origin = np.array([float(frame["origin"]["x"]), float(frame["origin"]["y"])])
|
||||
left_axis = np.array([float(v) for v in frame["left_xy"]])
|
||||
info: dict[str, Any] = {"left_m": 0.0, "right_m": 0.0, "surface_end": False}
|
||||
samples = list(samples)
|
||||
while design.get("slope_unclosed"):
|
||||
right_open, left_open = _open_ends(samples, design)
|
||||
added: list[dict[str, Any]] = []
|
||||
for side, is_open, sign in (("right", right_open, -1.0), ("left", left_open, 1.0)):
|
||||
if not is_open:
|
||||
continue
|
||||
edge = min(offsets) if sign < 0 else max(offsets)
|
||||
new_offsets = np.array([edge + sign * spacing * k for k in range(1, count + 1)])
|
||||
xy = origin[None, :] + left_axis[None, :] * new_offsets[:, None]
|
||||
z, valid = sampler.sample_xy(xy)
|
||||
if not np.all(valid):
|
||||
info["surface_end"] = True
|
||||
continue
|
||||
added.extend(
|
||||
{
|
||||
"offset_m": round(float(o), 6),
|
||||
"x": round(float(p[0]), 6),
|
||||
"y": round(float(p[1]), 6),
|
||||
"z": round(float(e), 6),
|
||||
"elevation_m": round(float(e), 6),
|
||||
"valid": True,
|
||||
}
|
||||
for o, p, e in zip(new_offsets, xy, z)
|
||||
)
|
||||
info[f"{side}_m"] += step_m
|
||||
if not added:
|
||||
break
|
||||
samples = sorted(samples + added, key=lambda s: float(s["offset_m"]))
|
||||
offsets = [offset for offset, _z in _valid_sorted(samples)]
|
||||
design = compute(samples)
|
||||
return samples, info
|
||||
|
||||
|
||||
def extend_unclosed_sections(
|
||||
longitudinal: dict[str, Any],
|
||||
cross_sections: list[dict[str, Any]],
|
||||
project_root: Path,
|
||||
standard: dict[str, Any] | None,
|
||||
sampler: Any,
|
||||
) -> int:
|
||||
"""저장 설계가 미교차인 측점을 넓혀 샘플·설계를 자리에서 갈고 횡단 파일에 남긴다. 넓힌 수."""
|
||||
from B06_Section.B06_Section_Router_Design import (
|
||||
USER_TOUCHED_KEYS,
|
||||
ford_drop_at,
|
||||
ford_surface_drops,
|
||||
stored_berm,
|
||||
stored_cut_slope,
|
||||
)
|
||||
from common_util.common_util_route_profile import design_elevation_from_longitudinal
|
||||
from config.config_system import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M
|
||||
|
||||
if sampler is None:
|
||||
return 0
|
||||
drops = ford_surface_drops(project_root)
|
||||
cross_dir = project_root / "B06_Section" / "cross_sections"
|
||||
changed = 0
|
||||
for section in cross_sections:
|
||||
design = section.get("design")
|
||||
if not isinstance(design, dict) or not design.get("slope_unclosed"):
|
||||
continue
|
||||
chainage = float(section.get("chainage_m", 0.0))
|
||||
|
||||
def compute(samples: list[dict[str, Any]], design=design, chainage=chainage) -> dict:
|
||||
return compute_cross_design(
|
||||
samples,
|
||||
design_elevation_from_longitudinal(longitudinal, chainage),
|
||||
ground_type=str(design.get("ground_type") or "ripping_rock"),
|
||||
section_mode=str(design.get("section_mode") or "left_cut"),
|
||||
ditch_side=design.get("ditch_side"),
|
||||
ditch_type=str(design.get("ditch_type") or "standard"),
|
||||
paved=bool(design.get("paved", False)),
|
||||
standard=standard,
|
||||
rock_boundary_offset_m=design.get(
|
||||
"rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M
|
||||
),
|
||||
two_stage_slope=bool(design.get("two_stage_slope", True)),
|
||||
cut_slope_ratio=stored_cut_slope(design),
|
||||
ditch_enabled=design.get("ditch_enabled"),
|
||||
ditch_choice=design.get("ditch_choice"),
|
||||
surface_drop_m=ford_drop_at(chainage, drops),
|
||||
berm=stored_berm(design),
|
||||
**curve_widening_args(section),
|
||||
)
|
||||
|
||||
try:
|
||||
result = extend_unclosed(
|
||||
section.get("samples") or [], section["frame"], compute, sampler
|
||||
)
|
||||
except (ValueError, KeyError):
|
||||
continue
|
||||
if result is None:
|
||||
continue
|
||||
samples, info = result
|
||||
if not info["left_m"] and not info["right_m"]:
|
||||
continue # 첫 걸음부터 지표면 밖 — 샘플이 그대로라 갈 것이 없음
|
||||
recomputed = compute(samples)
|
||||
for key in ("status", "pavement_suggested", *USER_TOUCHED_KEYS):
|
||||
if design.get(key) is not None:
|
||||
recomputed[key] = design[key]
|
||||
section["samples"] = samples
|
||||
section["design"] = recomputed
|
||||
cross_path = cross_dir / cross_filename(chainage)
|
||||
if cross_path.is_file():
|
||||
stored = json.loads(cross_path.read_text(encoding="utf-8"))
|
||||
atomic_write_json(cross_path, {**stored, "samples": samples})
|
||||
logger.info(
|
||||
"미교차 샘플 넓힘: 측점 %.3f 좌 +%sm 우 +%sm 지표면 끝 %s",
|
||||
chainage,
|
||||
info["left_m"],
|
||||
info["right_m"],
|
||||
info["surface_end"],
|
||||
)
|
||||
changed += 1
|
||||
return changed
|
||||
@@ -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
|
||||
|
||||
@@ -73,14 +73,13 @@ from B06_Section.B06_Section_Schema import (
|
||||
SectionRegenerateRequest,
|
||||
SectionSummaryResponse,
|
||||
)
|
||||
from B06_Section.B06_Section_Server_Calc_Prebuild import conversion_factors_for
|
||||
from B06_Section.B06_Section_Server_Calc_Prebuild import conversion_factors_for, haul_limits_for
|
||||
from common_util.common_util_auth import verify_session
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
|
||||
from common_util.common_util_workflow_state import get_workflow_state
|
||||
from config.config_db import get_db_pool, run_with_connection
|
||||
from config.config_system import (
|
||||
EARTHWORK_HAUL_EQUIPMENT_LIMITS_M,
|
||||
FOREST_ROAD_MIN_WIDTH_M,
|
||||
NATURAL_SPOIL_MIN_GROUND_SLOPE,
|
||||
SECTION_VERTICAL_EXAGGERATION,
|
||||
@@ -149,7 +148,7 @@ async def get_section_context(project_id: UUID) -> SectionContextResponse | JSON
|
||||
earthwork_conversion=await conversion_factors_for(project_id),
|
||||
haul_equipment_limits=[
|
||||
HaulEquipmentLimit(key=key, max_distance_m=limit)
|
||||
for key, limit in EARTHWORK_HAUL_EQUIPMENT_LIMITS_M
|
||||
for key, limit in await haul_limits_for(project_id)
|
||||
],
|
||||
natural_spoil_min_ground_slope=NATURAL_SPOIL_MIN_GROUND_SLOPE,
|
||||
)
|
||||
|
||||
@@ -27,6 +27,7 @@ from B06_Section.B06_Section_Server_Calc_Prebuild import (
|
||||
_mass_haul_context,
|
||||
conversion_factors_for,
|
||||
haul_inputs_for,
|
||||
haul_limits_for,
|
||||
)
|
||||
from common_util.common_util_node_bundle import run_bundle_json
|
||||
|
||||
@@ -66,6 +67,8 @@ async def compute_haul_plan(
|
||||
haul_inputs = await haul_inputs_for(project_id)
|
||||
# 곡선이 쓰는 계수도 프로젝트가 고른 값으로 — 토적표·운반표와 같은 값이어야 한다.
|
||||
factors = await conversion_factors_for(project_id)
|
||||
# 장비 거리 경계도 프로젝트 값으로 — 도쟈 한계거리를 고쳤으면 배분이 그 값으로 선다.
|
||||
limits = await haul_limits_for(project_id)
|
||||
try:
|
||||
output = await asyncio.to_thread(
|
||||
run_bundle_json,
|
||||
@@ -73,7 +76,7 @@ async def compute_haul_plan(
|
||||
_NPM_SCRIPT,
|
||||
{
|
||||
"haul_plan_for": result,
|
||||
"context": _mass_haul_context(haul_inputs, factors),
|
||||
"context": _mass_haul_context(haul_inputs, factors, limits),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
|
||||
@@ -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;
|
||||
@@ -77,6 +83,8 @@ if (input.haul_plan_for) {
|
||||
structure_spoil_points: input.context?.structure_spoil_points ?? null,
|
||||
// 잔토는 자연상태로 오고 곡선은 다짐상태다 — 담기 전에 ×C 하는 데 쓴다.
|
||||
conversion: input.context?.earthwork_conversion ?? null,
|
||||
// 화면이 그리는 계획 — 잔진동을 거른다(수량은 저장 정본 `haul_plan` 이 따로 낸다).
|
||||
drawing: true,
|
||||
});
|
||||
writeFileSync(outputPath, JSON.stringify({ haul_plan: plan ?? null }));
|
||||
process.exit(0);
|
||||
@@ -98,18 +106,30 @@ const result = conversion
|
||||
)
|
||||
: null;
|
||||
// 배분은 **서버만** 만든다 — 그래야 그 코드가 브라우저 번들에서 빠진다(2026-09-06).
|
||||
const plan = result
|
||||
? computeHaulPlan(result, input.context?.haul_equipment_limits, {
|
||||
collected_stone_deduction_m3: input.context?.collected_stone_deduction_m3 ?? null,
|
||||
collected_stone_by_ground_m3: input.context?.collected_stone_by_ground_m3 ?? null,
|
||||
collected_stone_ground_unknown_m3: input.context?.collected_stone_ground_unknown_m3 ?? null,
|
||||
structure_spoil_m3: input.context?.structure_spoil_m3 ?? null,
|
||||
structure_spoil_points: input.context?.structure_spoil_points ?? null,
|
||||
conversion: conversion ?? null,
|
||||
// 두 벌을 남긴다 — `haul_plan` 은 **거르지 않은** 수량 정본(B08), `haul_plan_drawing` 은
|
||||
// 잔진동을 거른 그림(B07 토적도). 거르기가 수량에 닿으면 운반이 사라진다(2026-09-14 브레인 ①).
|
||||
const planFor = (drawing: boolean) =>
|
||||
result
|
||||
? computeHaulPlan(result, input.context?.haul_equipment_limits, {
|
||||
collected_stone_deduction_m3: input.context?.collected_stone_deduction_m3 ?? null,
|
||||
collected_stone_by_ground_m3: input.context?.collected_stone_by_ground_m3 ?? null,
|
||||
collected_stone_ground_unknown_m3: input.context?.collected_stone_ground_unknown_m3 ?? null,
|
||||
structure_spoil_m3: input.context?.structure_spoil_m3 ?? null,
|
||||
structure_spoil_points: input.context?.structure_spoil_points ?? null,
|
||||
conversion: conversion ?? null,
|
||||
drawing,
|
||||
})
|
||||
: null;
|
||||
const plan = planFor(false);
|
||||
const drawingPlan = planFor(true);
|
||||
const massHaul = result
|
||||
? massHaulPayload(result, {
|
||||
...(plan ? { haul_plan: haulPlanPayload(plan) } : {}),
|
||||
...(drawingPlan ? { haul_plan_drawing: haulPlanPayload(drawingPlan) } : {}),
|
||||
})
|
||||
: null;
|
||||
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 }));
|
||||
|
||||
@@ -39,10 +39,12 @@ from B06_Section.B06_Section_Repository import (
|
||||
from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs
|
||||
from common_util.common_util_node_bundle import run_bundle_json
|
||||
from common_util.common_util_project_settings import (
|
||||
earthwork_conversion_factors,
|
||||
haul_equipment_limits,
|
||||
mixed_conversion_factors,
|
||||
quantity_settings,
|
||||
)
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
|
||||
from config.config_db import get_db_pool, run_with_connection
|
||||
from config.config_system import (
|
||||
EARTHWORK_CONVERSION_FACTORS,
|
||||
@@ -95,12 +97,25 @@ async def conversion_factors_for(project_id: Any) -> dict[str, dict[str, float]]
|
||||
except Exception:
|
||||
logger.warning("B06 프로젝트 경로를 못 찾음 — 기본 계수로 진행: project_id=%s", project_id)
|
||||
return {kind: dict(entry) for kind, entry in EARTHWORK_CONVERSION_FACTORS.items()}
|
||||
return earthwork_conversion_factors(quantity_settings(root))
|
||||
# 암은 구성비 가중 C(㉱ (나)) — 토적표·운반표와 같은 함수.
|
||||
return mixed_conversion_factors(quantity_settings(root))
|
||||
|
||||
|
||||
async def haul_limits_for(project_id: Any) -> list[tuple[str, float | None]]:
|
||||
"""이 프로젝트가 쓸 운반장비 거리 경계(도쟈 한계거리를 고쳤으면 그 값). 못 읽으면 기본값."""
|
||||
try:
|
||||
stored_path = await run_with_connection(get_project_storage_relative_path, project_id)
|
||||
root = resolve_stored_project_path(stored_path)
|
||||
except Exception:
|
||||
logger.warning("B06 프로젝트 경로를 못 찾음 — 기본 경계로 진행: project_id=%s", project_id)
|
||||
return list(EARTHWORK_HAUL_EQUIPMENT_LIMITS_M)
|
||||
return haul_equipment_limits(quantity_settings(root))
|
||||
|
||||
|
||||
def _mass_haul_context(
|
||||
haul_inputs: dict[str, Any] | None = None,
|
||||
factors: dict[str, dict[str, float]] | None = None,
|
||||
limits: list[tuple[str, float | None]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""유토곡선 계산에 필요한 값 — 화면이 `sections/context`로 받는 것과 같은 상수다.
|
||||
|
||||
@@ -118,9 +133,10 @@ def _mass_haul_context(
|
||||
# 프로젝트가 고른 계수가 있으면 그것, 없으면 정본 기본값.
|
||||
"earthwork_conversion": factors or EARTHWORK_CONVERSION_FACTORS,
|
||||
"natural_spoil_min_ground_slope": NATURAL_SPOIL_MIN_GROUND_SLOPE,
|
||||
# 프로젝트가 도쟈 한계거리를 고쳤으면 그 값, 없으면 정본 기본값.
|
||||
"haul_equipment_limits": [
|
||||
{"key": key, "max_distance_m": limit}
|
||||
for key, limit in EARTHWORK_HAUL_EQUIPMENT_LIMITS_M
|
||||
for key, limit in (limits or EARTHWORK_HAUL_EQUIPMENT_LIMITS_M)
|
||||
],
|
||||
# ⚠ B08 이 아직 이 값을 내지 않는다(2026-09-09) — 그때까지 `None`(아직 안 옴)이다.
|
||||
# 값을 내기 시작하면 여기에 실어 주기만 하면 통로가 이어진다.
|
||||
@@ -139,23 +155,43 @@ def _mass_haul_context(
|
||||
}
|
||||
|
||||
|
||||
def _surface_sampler(project_root: Path, params: dict[str, Any] | None) -> Any:
|
||||
"""확정 지표면 표고 조회기 — 못 열면 `None`(넓힘을 건너뛰고 종전대로 · 비치명)."""
|
||||
from common_util.common_util_surface_sampler import build_surface_sampler
|
||||
|
||||
try:
|
||||
return build_surface_sampler(
|
||||
project_root / "B04_PreProcess" / "models",
|
||||
str((params or {})["source_filter"]),
|
||||
str((params or {})["method"]),
|
||||
bool((params or {})["smooth"]),
|
||||
)
|
||||
except (FileNotFoundError, KeyError, OSError, ValueError) as exc:
|
||||
logger.warning("서버 재계산: 확정 지표면을 못 열어 미교차 넓힘을 건너뜀 — %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _enforce_stored_designs(
|
||||
longitudinal: dict[str, Any],
|
||||
sections: list[dict[str, Any]],
|
||||
project_root: Path,
|
||||
standard: dict[str, Any] | None,
|
||||
sampler: Any = None,
|
||||
) -> None:
|
||||
"""저장분 설계를 **쓰는 시점에** 바로잡는다 — 포장 구간·세월교 노면 하강.
|
||||
|
||||
예전에는 상세를 **읽을 때마다** 돌려 화면이 볼 때만 맞았다(저장분은 낡은 채로).
|
||||
2026-09-06 사용자 확정대로 「읽기는 영구저장소에서 가져오기만」이므로 이쪽으로 옮겼다.
|
||||
"""
|
||||
from B06_Section.B06_Section_Engine_SampleExtend import extend_unclosed_sections
|
||||
from B06_Section.B06_Section_Engine_SpoilFill import enforce_spoil_fills
|
||||
from B06_Section.B06_Section_Router_Design import (
|
||||
enforce_ford_surface_drops,
|
||||
enforce_pavement_ranges,
|
||||
)
|
||||
|
||||
# ⚠ 미교차 샘플 넓힘이 **맨 앞**이다 — 뒤의 보정들이 넓힌 지반 위에서 다시 계산해야 한다(㉳ (가)).
|
||||
extend_unclosed_sections(longitudinal, sections, project_root, standard, sampler)
|
||||
enforce_pavement_ranges(longitudinal, sections, project_root, standard)
|
||||
enforce_ford_surface_drops(longitudinal, sections, project_root, standard)
|
||||
# ⚠ 사토장은 **맨 뒤**다 — 앞의 두 보정이 설계를 다시 계산하면서 사토장 칸을 지운다.
|
||||
@@ -183,11 +219,12 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
|
||||
pool = get_db_pool()
|
||||
# 구조물 몫(채집석 공제·구조물 잔토)도 함께 받아 온다 — **B08 이 낸 값**이고, 안 넘기면
|
||||
# 통로만 있고 값이 안 흐른다(2026-09-09 실측: 공제가 늘 `None` 이라 사토가 안 줄었다).
|
||||
response, stored_path, longitudinal_row, haul_inputs = await asyncio.gather(
|
||||
response, stored_path, longitudinal_row, haul_inputs, surface = await asyncio.gather(
|
||||
get_section_detail(project_uuid, route_id),
|
||||
run_with_connection(get_project_storage_relative_path, project_uuid),
|
||||
run_with_connection(get_longitudinal_section, project_uuid, route_id),
|
||||
haul_inputs_for(project_uuid),
|
||||
run_with_connection(get_surface_confirmation_params, str(project_uuid)),
|
||||
)
|
||||
marks.append(("종횡단 상세+DB 조회(병렬)", time.perf_counter()))
|
||||
payload = getattr(response, "model_dump", None)
|
||||
@@ -202,8 +239,16 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
|
||||
standard = stored_standard_cross_section(longitudinal_row)
|
||||
# 포장 구간·세월교 보정 — 고쳐진 설계 위에서 면적·유토곡선이 나와야 한다.
|
||||
before = [json.dumps(item.get("design"), sort_keys=True, default=str) for item in sections]
|
||||
# 확정 지표면 — 미교차 측점이 있을 때만 연다(샘플 넓힘 ㉳ (가) · 없으면 여는 비용을 안 씀).
|
||||
unclosed = any((item.get("design") or {}).get("slope_unclosed") for item in sections)
|
||||
sampler = await asyncio.to_thread(_surface_sampler, project_root, surface) if unclosed else None
|
||||
await asyncio.to_thread(
|
||||
_enforce_stored_designs, detail.get("longitudinal") or {}, sections, project_root, standard
|
||||
_enforce_stored_designs,
|
||||
detail.get("longitudinal") or {},
|
||||
sections,
|
||||
project_root,
|
||||
standard,
|
||||
sampler,
|
||||
)
|
||||
fixed = [
|
||||
item
|
||||
@@ -220,7 +265,9 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
|
||||
{
|
||||
"detail": detail,
|
||||
"context": _mass_haul_context(
|
||||
haul_inputs, earthwork_conversion_factors(quantity_settings(project_root))
|
||||
haul_inputs,
|
||||
mixed_conversion_factors(quantity_settings(project_root)),
|
||||
haul_equipment_limits(quantity_settings(project_root)),
|
||||
),
|
||||
},
|
||||
)
|
||||
@@ -229,7 +276,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
|
||||
@@ -265,6 +325,10 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
|
||||
updated = await merge_cross_section_designs(
|
||||
connection, route_id=route_id, entries=area_entries, replace=False
|
||||
)
|
||||
if extra_walls:
|
||||
await merge_cross_section_designs(
|
||||
connection, route_id=route_id, entries=extra_walls, replace=False
|
||||
)
|
||||
if isinstance(mass_haul, dict):
|
||||
await merge_longitudinal_section_data(
|
||||
connection, route_id=route_id, data_patch={"mass_haul": mass_haul}
|
||||
|
||||
@@ -10,12 +10,17 @@
|
||||
* DOM·SVG 를 부르지 않는다. 브라우저에서도 Node 에서도 같은 결과가 나와야 한다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { computeStructureAreas } from "@util/common_util_cross_structure_areas";
|
||||
import { computeStructureAreas, wallBodyInFillM2 } from "@util/common_util_cross_structure_areas";
|
||||
import type { CrossSection } from "./B06_Section_Api_Fetch";
|
||||
import { computeBoxLayout, DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box_Geom";
|
||||
import { REVET_EMBED_DEPTH_M } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import { DEFAULT_BASIN_ADJUST, ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import { computeCardCulvert, culvertLinkFor } from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
import type { WallAdjust, WallLayout } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import {
|
||||
computeCardCulvert,
|
||||
culvertLinkFor,
|
||||
tierSpanOf,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
import type {
|
||||
ExtraWallControl,
|
||||
InletStructureControl,
|
||||
@@ -144,6 +149,22 @@ export const STRUCTURE_ROW_KEYS = [
|
||||
"pipe_length_m",
|
||||
] as const;
|
||||
|
||||
/** 폐회로 면적 입력(설계선·지반선·트림) — 트림이나 설계선이 모자라면 null. */
|
||||
function areaInputOf(section: CrossSection, layouts: StoredLayouts) {
|
||||
const trim = trimOfLayouts(layouts);
|
||||
const designLine = layouts.design.design_line;
|
||||
if (!trim || !Array.isArray(designLine) || designLine.length < 2) return null;
|
||||
const ground = section.samples
|
||||
.filter((sample) => sample.valid !== false && typeof sample.elevation_m === "number")
|
||||
.map((sample) => ({ offset: sample.offset_m ?? 0, elevation: sample.elevation_m as number }))
|
||||
.sort((a, b) => a.offset - b.offset);
|
||||
return {
|
||||
designLine: designLine as Array<{ offset_m: number; elevation_m: number }>,
|
||||
ground,
|
||||
trim,
|
||||
};
|
||||
}
|
||||
|
||||
/** 측점 하나의 폐회로 면적 — 구조물이 없으면 null(표준 계산값이 이미 맞다). */
|
||||
function areaRowOf(
|
||||
section: CrossSection,
|
||||
@@ -176,17 +197,11 @@ function areaRowOf(
|
||||
typeof pipeLengthM === "number" && pipeLengthM > 0
|
||||
? { chainage_m: section.chainage_m, pipe_length_m: Number(pipeLengthM.toFixed(4)) }
|
||||
: null;
|
||||
const trim = trimOfLayouts(layouts);
|
||||
const areaInput = areaInputOf(section, layouts);
|
||||
if (!areaInput) return pipeRow;
|
||||
const design = layouts.design;
|
||||
if (!trim || !Array.isArray(design.design_line) || design.design_line.length < 2) return pipeRow;
|
||||
const ground = section.samples
|
||||
.filter((sample) => sample.valid !== false && typeof sample.elevation_m === "number")
|
||||
.map((sample) => ({ offset: sample.offset_m ?? 0, elevation: sample.elevation_m as number }))
|
||||
.sort((a, b) => a.offset - b.offset);
|
||||
const areas = computeStructureAreas({
|
||||
designLine: design.design_line as Array<{ offset_m: number; elevation_m: number }>,
|
||||
ground,
|
||||
trim,
|
||||
...areaInput,
|
||||
rockBoundaryOffsetM:
|
||||
typeof design.rock_boundary_offset_m === "number" ? design.rock_boundary_offset_m : null,
|
||||
});
|
||||
@@ -219,6 +234,82 @@ export function structureAreaRows(
|
||||
.filter((row): row is Record<string, number> => !!row);
|
||||
}
|
||||
|
||||
/** 선 다단 벽 한 매 — B08 이 줄을 세우는 값(파이썬 `B08_Quantity_Engine_Pipe.facility_structures`). */
|
||||
export interface BuiltExtraWall {
|
||||
key: string;
|
||||
side: "outlet" | "basin";
|
||||
form: string;
|
||||
/** 순수 높이(m) — 바닥~상단(근입 0.5 포함). 지형에 맞춰 선 값. */
|
||||
height_m: number;
|
||||
/** 사용자가 높이·형태를 적었나 — 안 적었으면 B08 이 미확정으로 셈. */
|
||||
height_set: boolean;
|
||||
form_set: boolean;
|
||||
before_m: number;
|
||||
after_m: number;
|
||||
/** 벽 몸 중 성토 폐회로 안에 든 넓이(㎡) — 구조물 면적을 안 빼서(2026-09-06 확정) 성토에도 셈.
|
||||
* B08 이 「두 번 셈」 사유로 올림(2026-09-14 브레인). 면적을 못 내는 측점은 null. */
|
||||
fill_overlap_m2: number | null;
|
||||
}
|
||||
|
||||
export interface ExtraWallRow {
|
||||
chainage_m: number;
|
||||
extra_walls: BuiltExtraWall[];
|
||||
wall_fill_overlap: Record<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 관(·독립 기슭막이) 주인 측점마다 **실제로 선** 다단 벽 목록(④, 2026-09-14).
|
||||
*
|
||||
* 단 수·높이는 지형이 정해 요청보다 적게 설 수 있음 — 그래서 요청 칸(`extra_wall_counts`)이 아니라
|
||||
* 기하가 세운 결과를 남김(관 연장 `pipe_length_m` 과 같은 길). 주인 측점에는 빈 목록도 실어 옛 값을 지움.
|
||||
* `wall_fill_overlap` — 관 기준벽 역할별 벽 몸 ∩ 성토 폐회로(㎡). B08 기준벽 줄 비고.
|
||||
*/
|
||||
export function extraWallRows(sections: readonly CrossSection[]): ExtraWallRow[] {
|
||||
const rows: ExtraWallRow[] = [];
|
||||
for (const section of sections) {
|
||||
if (!section.culvert) continue;
|
||||
const owner = pipeOwnerChainage(section, sections);
|
||||
if (owner !== null && Math.abs(owner - section.chainage_m) > CHAINAGE_TOLERANCE_M) continue;
|
||||
const layouts = computeStoredLayouts(section, sections);
|
||||
const culvert = layouts?.culvert;
|
||||
if (!layouts || !culvert) continue;
|
||||
const areaInput = areaInputOf(section, layouts);
|
||||
const walls: BuiltExtraWall[] = [];
|
||||
const built: Array<["outlet" | "basin", WallLayout[], WallAdjust[]]> = [
|
||||
["outlet", culvert.extraWalls, culvert.revetShift.extras],
|
||||
["basin", culvert.basinExtras, culvert.revetShift.basinExtras],
|
||||
];
|
||||
for (const [side, list, applied] of built) {
|
||||
list.forEach((wall, index) => {
|
||||
const key = `${side === "basin" ? "bextra" : "extra"}${index}`;
|
||||
const span = tierSpanOf(section, key);
|
||||
walls.push({
|
||||
key,
|
||||
side,
|
||||
form: wall.form ?? "",
|
||||
height_m: Number((wall.height + REVET_EMBED_DEPTH_M).toFixed(3)),
|
||||
height_set: applied[index]?.h != null,
|
||||
form_set: section.design?.revet_adjust?.[key]?.m != null,
|
||||
before_m: span.beforeM,
|
||||
after_m: span.afterM,
|
||||
fill_overlap_m2: areaInput
|
||||
? Number(wallBodyInFillM2(wall.points, areaInput).toFixed(3))
|
||||
: null,
|
||||
});
|
||||
});
|
||||
}
|
||||
// 관 기준벽(유입·유출)도 같은 겹침 — 다단에만 사유가 뜨면 「다단만 문제」로 읽힘(㉡ 2026-09-14).
|
||||
const wall_fill_overlap: Record<string, number> = {};
|
||||
for (const wall of culvert.walls) {
|
||||
if (areaInput && (wall.role === "inlet" || wall.role === "outlet")) {
|
||||
wall_fill_overlap[wall.role] = Number(wallBodyInFillM2(wall.points, areaInput).toFixed(3));
|
||||
}
|
||||
}
|
||||
rows.push({ chainage_m: section.chainage_m, extra_walls: walls, wall_fill_overlap });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** 낸 면적을 측점 자료에 도로 얹는다 — 유토곡선이 고쳐진 면적 위에서 쌓이도록. */
|
||||
export function applyStructureAreaRows(
|
||||
sections: readonly CrossSection[],
|
||||
|
||||
@@ -49,6 +49,40 @@ function fillSlopeInfo(section: CrossSection): HTMLElement | null {
|
||||
return info;
|
||||
}
|
||||
|
||||
/**
|
||||
* 시설을 **기본값으로 그렸거나 못 그린 까닭** `[보이는 글, 툴팁]` (2026-09-14 브레인 판정 ②③).
|
||||
* 저장만 안 하는 것으로는 모자람 — 그림이 설계값처럼 보이면 기본값이 몰래 확정으로 굳던 병의 잔재.
|
||||
*/
|
||||
function facilityNotes(section: CrossSection): Array<[string, string]> {
|
||||
const notes: Array<[string, string]> = [];
|
||||
const sets: Array<[string, string[] | undefined]> = [
|
||||
["BOX암거", section.box?.defaulted],
|
||||
["세월교", section.ford?.defaulted],
|
||||
["물넘이포장", section.ford_pavement?.defaulted],
|
||||
];
|
||||
for (const [name, items] of sets) {
|
||||
if (!items?.length) continue;
|
||||
notes.push([
|
||||
`⚠ ${name} 기본값(미확정)`,
|
||||
`B05 시설 칸에 안 적어 기본값으로 그림 — ${items.join(" · ")} · 적으면 그 값으로 그림`,
|
||||
]);
|
||||
}
|
||||
const own = section.culvert?.hidden_pipe ? section.culvert : null;
|
||||
if (own && [own.inlet, own.outlet].some((side) => !side.revet_height_m)) {
|
||||
notes.push([
|
||||
"⚠ 기슭막이 높이 없음 — 근입만 그림",
|
||||
"독립 기슭막이 높이는 설계자 입력 — 계획홍수위 + 0.5~0.7m(사방기술교본 2-나:141 · 3-가:181) · 비우면 수량도 미확정 · B05 시설 칸에 적으면 그림",
|
||||
]);
|
||||
}
|
||||
if (section.ford_pavement && !section.ford_pavement.depth_m) {
|
||||
notes.push([
|
||||
"⚠ 월류 높이 없음 — 파임 안 그림",
|
||||
"물넘이포장 월류 높이를 안 적어 파인 노면을 안 그림(수치를 지어내지 않음) — B05 시설 칸에 월류 높이를 적으면 그림",
|
||||
]);
|
||||
}
|
||||
return notes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 제목행을 만들어 카드에 붙인다(설계 조작이 있으면 조작 바까지).
|
||||
*
|
||||
@@ -86,6 +120,13 @@ export function appendCardHeader(
|
||||
openSlope.title = L("B06_Cross_SlopeUnclosed_Tip");
|
||||
meta.append(openSlope);
|
||||
}
|
||||
for (const [text, tip] of facilityNotes(section)) {
|
||||
const note = document.createElement("span");
|
||||
note.className = "b06-cross-card__warning";
|
||||
note.textContent = text;
|
||||
note.title = tip;
|
||||
meta.append(note);
|
||||
}
|
||||
const structureName = section.structure;
|
||||
if (structureName) {
|
||||
const structure = document.createElement("span");
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
import {
|
||||
REVET_EMBED_DEPTH_M,
|
||||
FILL_SLOPE_RATIO_MIN,
|
||||
REVET_LEAN_RATIO,
|
||||
pipeWallThicknessM,
|
||||
revetHeightLimit,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Geom";
|
||||
@@ -183,7 +182,7 @@ export function appendCulvertOverlay(
|
||||
`${roleLabel(wall.role)} 기슭막이 ${wall.form ?? ""} H=${(
|
||||
wall.height + REVET_EMBED_DEPTH_M
|
||||
).toFixed(1)}m` +
|
||||
`(상단 = 사면선 접점, 전면 1:${REVET_LEAN_RATIO}` +
|
||||
`(상단 = 사면선 접점, 전면 1:${wall.lean}(품셈 13-4-4 [주]⑪ 표준경사)` +
|
||||
`, 높이 한계 ${revetHeightLimit(wall.form).toFixed(1)}m — 교본 7-3)` +
|
||||
(wall.floatGapM > 0.01
|
||||
? ` · ⚠ 바닥 원지반 이격 ${wall.floatGapM.toFixed(2)}m — 하부 지지 구조물 별도(추가 예정)`
|
||||
|
||||
@@ -16,7 +16,8 @@ export const MIN_PIPE_COVER_M = 0.5;
|
||||
/** 기슭막이 벽 두께(m) — 실무 견치돌 뒷길이 관측치 45㎝(울진 L3=45). 표시용 형상 값. */
|
||||
export const REVET_THICKNESS_M = 0.45;
|
||||
|
||||
/** 기슭막이 전면 기울기(1:n) — 돌쌓기 전면 1:0.3(교본 7-3). 벽이 사면 쪽으로 기운다. */
|
||||
/** 기슭막이 전면 기울기(1:n) **종전값** — 돌쌓기가 아닌 형태(콘크리트·돌망태…)와 성토/절토를
|
||||
* 못 가른 자리만 이 값. 돌쌓기 벽은 `B06_Section_UI_Cross_Lean.leanOf`(품셈 13-4-4 [주]⑪ 표준경사). */
|
||||
export const REVET_LEAN_RATIO = 0.3;
|
||||
|
||||
/** 돌붙임 두께(m) — 실무 돌붙임 L3=45(울진 1공구 수량집계표). */
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* (2026-08-22 사용자 확정 규칙):
|
||||
* · 기슭막이 사이 성토사면은 **정확히 1:1.2** — 벽 높이를 자리에 맞춰 역산한다.
|
||||
* · 아랫단 벽 상단은 윗단 벽 **하단(수평 기초선)을 뚫고 올라갈 수 없다**.
|
||||
* · 다음 단 성토선 시작점 = 윗단 벽 **하단 수평선 +0.5m와 전면 경사선(1:0.3)의
|
||||
* · 다음 단 성토선 시작점 = 윗단 벽 **하단 수평선 +0.5m와 전면 경사선(1:n · 표준경사)의
|
||||
* 교차점**(배관 기슭막이의 관 하단 꼭짓점과 같은 자리 — 배관만 없다).
|
||||
* · 그 시작점이 원지반 아래면(벽이 0.5m 이상 묻힘) **성토 불필요** — 다단 종료.
|
||||
* ========================================================================== */
|
||||
@@ -48,6 +48,8 @@ export interface OutletExtrasInput {
|
||||
ownerForm?: string | null;
|
||||
/** 1회성 등간격 배치(2026-08-22 사용자 ①) — 단별 d를 사면 구간이 같아지게 재계산. */
|
||||
equalize?: boolean;
|
||||
/** 단 벽의 전면 기울기 — 형태·순수 높이로 표준경사를 고름(없으면 종전 1:0.3). */
|
||||
leanFor?: (form: string, pureHeightM: number) => number;
|
||||
}
|
||||
|
||||
export interface OutletExtrasResult {
|
||||
@@ -167,9 +169,9 @@ export function inletGroundConnector(
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 벽 중심(하단 중점)에서 이음선 상단점까지의 수평거리 — 높이에 따라 커진다. */
|
||||
function jointRunOf(height: number): number {
|
||||
return 0.25 * REVET_THICKNESS_M + (REVET_LEAN_RATIO / 2) * height;
|
||||
/** 벽 중심(하단 중점)에서 이음선 상단점까지의 수평거리 — 높이·기울기에 따라 커진다. */
|
||||
function jointRunOf(height: number, lean: number): number {
|
||||
return 0.25 * REVET_THICKNESS_M + (lean / 2) * height;
|
||||
}
|
||||
|
||||
/** 끝 성토부선 — src에서 1:1.2로 내려가며 원지반을 만나면 끝(지반이 높으면 지반 따름). */
|
||||
@@ -226,16 +228,17 @@ function buildExtraWall(
|
||||
material: RevetMaterial,
|
||||
floatGapM: number,
|
||||
form: string | null = null,
|
||||
lean: number = REVET_LEAN_RATIO,
|
||||
): WallLayout {
|
||||
const thickness = REVET_THICKNESS_M;
|
||||
const baseWidth = thickness * 1.5 + REVET_LEAN_RATIO * height;
|
||||
const baseWidth = thickness * 1.5 + lean * height;
|
||||
const backOffset = anchor.offset - outward * (baseWidth / 2);
|
||||
const topJoint = backOffset + outward * (thickness / 2);
|
||||
const topElevation = anchor.elevation + height;
|
||||
const topBack: OffsetPoint = { offset: backOffset, elevation: topElevation };
|
||||
const topFront = topJoint + outward * thickness;
|
||||
const frontXAt = (elevation: number): number =>
|
||||
topFront + outward * REVET_LEAN_RATIO * (topElevation - elevation);
|
||||
topFront + outward * lean * (topElevation - elevation);
|
||||
// 하단 = 기준선(anchor) 아래 근입 0.5m **고정** — 원지반에 묻혀도 바닥을 더
|
||||
// 내리지 않는다. 벽 형상은 높이값으로만 정한다(2026-08-23 사용자 확정 —
|
||||
// 종전 전면 발끝 지반 추적 수렴 삭제).
|
||||
@@ -256,6 +259,7 @@ function buildExtraWall(
|
||||
height,
|
||||
floatGapM,
|
||||
material,
|
||||
lean,
|
||||
outward,
|
||||
topBack,
|
||||
topJoint: { offset: topJoint, elevation: topElevation },
|
||||
@@ -281,7 +285,6 @@ function buildExtraWall(
|
||||
export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult {
|
||||
const { outward, groundAt, limitOffset } = input;
|
||||
const thickness = REVET_THICKNESS_M;
|
||||
const heightDenominator = 1 - REVET_LEAN_RATIO / 2 / FILL_SLOPE_RATIO_MIN;
|
||||
/** 다단 1회 전개 — plan: "user"(조작값) / "greedy"(등간격 1차 근사) / d 명시 배열. */
|
||||
const cascadeOnce = (plan: "user" | "greedy" | number[]): OutletExtrasResult => {
|
||||
const walls: WallLayout[] = [];
|
||||
@@ -315,6 +318,9 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
|
||||
/** 기준선(근입 위)~상단 — 도형·자리 계산은 종전대로 이 값으로 한다.
|
||||
* 지형이 요청 높이만큼 안 나오면 아래 스캔 뒤 **낮춰서** 바닥을 지반에 앉힌다. */
|
||||
let exposed = height - REVET_EMBED_DEPTH_M;
|
||||
// 전면 기울기 — 형태·요청 높이로 표준경사(2026-09-14 B5). 자리 닫힌식의 분모도 따라감.
|
||||
const lean = input.leanFor?.(form, height) ?? REVET_LEAN_RATIO;
|
||||
const heightDenominator = 1 - lean / 2 / FILL_SLOPE_RATIO_MIN;
|
||||
|
||||
/** 자리 x(벽 하단 중점)에 지반 안착 + 상단이 성토선에 닿는 데 필요한 높이. */
|
||||
const heightAt = (x: number): number => {
|
||||
@@ -350,7 +356,7 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
|
||||
}
|
||||
if (autoOffset === null) autoOffset = bestOffset;
|
||||
if (autoOffset === null) break; // 세울 만한 지형이 아니다 — 되는 만큼만.
|
||||
const autoJointRun = (autoOffset - src.offset) * outward - jointRunOf(exposed);
|
||||
const autoJointRun = (autoOffset - src.offset) * outward - jointRunOf(exposed, lean);
|
||||
/** 자동 자리(선반 0·수직 0)의 벽 상단 표고 — 조작은 여기서부터 잰다. */
|
||||
const autoTop = src.elevation - autoJointRun / FILL_SLOPE_RATIO_MIN;
|
||||
// 관통 금지: 상단 ≤ 윗단 하단 → 그만큼은 반드시 내려간다.
|
||||
@@ -378,7 +384,7 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
|
||||
const placeable = (xShift: number, vShift: number): boolean => {
|
||||
const topE = autoTop - vShift;
|
||||
const aX = autoOffset + outward * xShift;
|
||||
const jointX = aX - outward * jointRunOf(exposed);
|
||||
const jointX = aX - outward * jointRunOf(exposed, lean);
|
||||
if (
|
||||
Math.max(groundAt(jointX), groundAt(jointX + outward * REVET_THICKNESS_M)) >=
|
||||
topE - 0.01
|
||||
@@ -388,7 +394,7 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
|
||||
const bottom = bottomAt(aX, baseE);
|
||||
const startE = bottom + REVET_EMBED_DEPTH_M;
|
||||
const topFrontX = jointX + outward * REVET_THICKNESS_M;
|
||||
const startX = topFrontX + outward * REVET_LEAN_RATIO * (topE - startE);
|
||||
const startX = topFrontX + outward * lean * (topE - startE);
|
||||
if (groundAt(startX) < startE + 0.05) return true;
|
||||
// 매몰 — 3도 절토선(계류 쪽 내림)이 원지반과 다시 만나면 허용, 아니면 이동 금지.
|
||||
return (
|
||||
@@ -438,6 +444,7 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
|
||||
material,
|
||||
Math.max(0, base - groundBase),
|
||||
form,
|
||||
lean,
|
||||
);
|
||||
// 요청한 좌우 이동을 지형이 막았으면 그 양을 남긴다 — 「눌러도 안 움직인다」의 까닭을
|
||||
// 툴팁으로 알리기 위함이다(2026-09-06 실측: 다섯 측점 중 넷이 1.0m 요청에 0.0m 이동).
|
||||
@@ -479,8 +486,7 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
|
||||
const startElevation = wall.bottomBack.elevation + REVET_EMBED_DEPTH_M;
|
||||
src = {
|
||||
offset:
|
||||
wall.points[2].offset +
|
||||
outward * REVET_LEAN_RATIO * (wall.topJoint.elevation - startElevation),
|
||||
wall.points[2].offset + outward * wall.lean * (wall.topJoint.elevation - startElevation),
|
||||
elevation: startElevation,
|
||||
};
|
||||
prevBottom = wall.bottomBack.elevation;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* B06_Section_UI_Cross_Culvert_Geom.ts
|
||||
* 배수관 세트(배관·기슭막이·성토부) **기하 계산** — 그리기(`_Cross_Culvert.ts`)와 분리.
|
||||
* 백엔드 `section.culvert` 제원을 좌표로 옮긴다(치수 결정 금지). 기슭막이는 전면
|
||||
* 1:0.3 평행사변형, 관 끝단면은 구조물 변과 평행, 성토 경사선은 벽 접점에서 끊는다
|
||||
* 1:n 평행사변형(n = 품셈 표준경사 · `leanOf`), 관 끝단면은 구조물 변과 평행, 성토 경사선은 벽 접점에서 끊는다
|
||||
* (designTrim). 유입 = 상단측. 집수정은 `_Basin.ts`, 성토부·다단은 `_Extra.ts`.
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
revetTargetHeight,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import { leanOf } from "./B06_Section_UI_Cross_Lean";
|
||||
import { ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import type {
|
||||
BasinLayout,
|
||||
@@ -171,8 +172,19 @@ export function computeCulvertLayout(
|
||||
const adjustOf = (value?: WallAdjust): WallAdjust => ({ ...ZERO_ADJUST, ...(value ?? {}) });
|
||||
const adjInlet = adjustOf(revetShift?.inlet);
|
||||
const adjOutlet = adjustOf(revetShift?.outlet);
|
||||
const pipeWallSpec = (spec: CulvertSideSpec, adjust: WallAdjust) =>
|
||||
revetWallSpec(spec, adjust, culvert.hidden_pipe === true, diameter);
|
||||
// 전면 기울기 = 표준경사 표(단면유형 × 설치 측 × 형태 × 높이 — B08 과 한 벌, 2026-09-14 B5).
|
||||
const pipeWallSpec = (spec: CulvertSideSpec, adjust: WallAdjust) => {
|
||||
const wall = revetWallSpec(spec, adjust, culvert.hidden_pipe === true, diameter);
|
||||
const lean = leanOf({
|
||||
form: wall.form,
|
||||
height_m: wall.pureHeight,
|
||||
section_mode: section.design?.section_mode,
|
||||
side: culvert.hidden_pipe ? spec.face_side : null,
|
||||
face_role: spec.face_role,
|
||||
face_slope_ratio: spec.face_slope_ratio,
|
||||
});
|
||||
return { ...wall, lean };
|
||||
};
|
||||
const inletWallSpec = pipeWallSpec(culvert.inlet, adjInlet);
|
||||
const outletWallSpec = pipeWallSpec(culvert.outlet, adjOutlet);
|
||||
// 조정창 선택지 가용성 — 판정은 Basin이 맡는다.
|
||||
@@ -204,14 +216,21 @@ export function computeCulvertLayout(
|
||||
edge: { offset_m: number },
|
||||
outward: number,
|
||||
wallHeight: number,
|
||||
): number => edge.offset_m + outward * (fillWallBaseWidth(wallHeight) / 2 - REVET_TRAP_TOP_M);
|
||||
lean: number,
|
||||
): number =>
|
||||
edge.offset_m + outward * (fillWallBaseWidth(wallHeight, lean) / 2 - REVET_TRAP_TOP_M);
|
||||
if (!basinReason && designAt) {
|
||||
const inletBaseAt = (offset: number): number =>
|
||||
Math.min(groundAt(offset), invertCap(inletInfo.edge));
|
||||
// 4축 배치는 공용 풀이(placeInletWall — Solve). 유입을 내리면 유출도 따라
|
||||
// 내려가므로, 유출이 못 받으면 유입도 못 내려간다(outletGuard).
|
||||
const placed = placeInletWall({
|
||||
autoOffset: slopeZeroAnchor(inletInfo.edge, inletInfo.outward, inletWallSpec.height),
|
||||
autoOffset: slopeZeroAnchor(
|
||||
inletInfo.edge,
|
||||
inletInfo.outward,
|
||||
inletWallSpec.height,
|
||||
inletWallSpec.lean,
|
||||
),
|
||||
outward: inletInfo.outward,
|
||||
height: inletWallSpec.height,
|
||||
baseElevation0: inletInfo.edge.elevation_m - inletWallSpec.height,
|
||||
@@ -238,6 +257,7 @@ export function computeCulvertLayout(
|
||||
outletInfo.limit,
|
||||
),
|
||||
limitOffset: outletInfo.limit,
|
||||
lean: outletWallSpec.lean,
|
||||
},
|
||||
});
|
||||
appliedAdjust.inlet.x = placed.x;
|
||||
@@ -308,6 +328,7 @@ export function computeCulvertLayout(
|
||||
vertical: WallVertical | null = null,
|
||||
material: RevetMaterial = "dry",
|
||||
formLabel: string | null = null,
|
||||
lean: number = REVET_LEAN_RATIO,
|
||||
): WallLayout | null => {
|
||||
// spec의 "집수정"은 ruleReason에 이미 반영 — 여기서 되살리면 revet 선택이 깨진다.
|
||||
const reason: BasinLayout["reason"] | null = forceBasinReason;
|
||||
@@ -340,13 +361,13 @@ export function computeCulvertLayout(
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// 형상: 배면 수직 + 계류측 1:0.3 평행사변형 띠. 높이는 vertical(계산용)이 들고 온다.
|
||||
// 형상: 배면 수직 + 계류측 1:n 평행사변형 띠(n = 표준경사). 높이는 vertical(계산용)이 들고 온다.
|
||||
const height =
|
||||
vertical?.height ?? Math.min(revetTargetHeight(diameter), revetHeightLimit(spec.revet_form));
|
||||
if (!(height > 0.05)) return null;
|
||||
const floatGapM = vertical?.floatGapM ?? 0;
|
||||
// 자리 기준 = **하단선 중점**(2026-08-21) — anchor.elevation은 그 자리 관 invert.
|
||||
const baseWidth = thickness * 1.5 + REVET_LEAN_RATIO * height;
|
||||
const baseWidth = thickness * 1.5 + lean * height;
|
||||
const backOffset = anchor.offset - outward * (baseWidth / 2);
|
||||
const topJoint = backOffset + outward * (thickness / 2);
|
||||
const topElevation = anchor.elevation + height;
|
||||
@@ -357,7 +378,7 @@ export function computeCulvertLayout(
|
||||
// 원지반에 묻혀도 바닥을 더 내리지 않는다. 벽 형상은 높이값으로만 정한다
|
||||
// (2026-08-23 사용자 확정 — 종전 전면 발끝 지반 추적 수렴 삭제).
|
||||
const frontXAt = (elevation: number): number =>
|
||||
topFront + outward * REVET_LEAN_RATIO * (topElevation - elevation);
|
||||
topFront + outward * lean * (topElevation - elevation);
|
||||
const bottomElevation = anchor.elevation - REVET_EMBED_DEPTH_M;
|
||||
const bottomBack: OffsetPoint = { offset: backOffset, elevation: bottomElevation };
|
||||
const bottomFront: OffsetPoint = {
|
||||
@@ -376,6 +397,7 @@ export function computeCulvertLayout(
|
||||
height,
|
||||
floatGapM,
|
||||
material,
|
||||
lean,
|
||||
outward,
|
||||
topBack,
|
||||
topJoint: { offset: topJoint, elevation: topElevation },
|
||||
@@ -411,6 +433,7 @@ export function computeCulvertLayout(
|
||||
wallVertical.inlet,
|
||||
inletWallSpec.material,
|
||||
inletWallSpec.form,
|
||||
inletWallSpec.lean,
|
||||
);
|
||||
// 유출 벽 밑 = 그 자리 원지반(역경사는 유입 invert로 클램프 — 2026-08-21).
|
||||
const invertAt = (offset: number): number => Math.min(groundAt(offset), inlet.elevation);
|
||||
@@ -421,6 +444,7 @@ export function computeCulvertLayout(
|
||||
outletInfo.edge,
|
||||
outletInfo.outward,
|
||||
outletWallSpec.height,
|
||||
outletWallSpec.lean,
|
||||
);
|
||||
const outletZeroInvert = outletInfo.edge.elevation_m - outletWallSpec.height;
|
||||
const outletLineInvertAt = (offset: number): number =>
|
||||
@@ -461,6 +485,7 @@ export function computeCulvertLayout(
|
||||
wallVertical.outlet,
|
||||
outletWallSpec.material,
|
||||
outletWallSpec.form,
|
||||
outletWallSpec.lean,
|
||||
);
|
||||
// ── 관 축 확정 — 관 하단선은 시작점과 유출 벽 전면 기준선 교차점을 잇는다.
|
||||
const pipeStart = basinPipeEnd ?? inlet;
|
||||
@@ -471,8 +496,7 @@ export function computeCulvertLayout(
|
||||
const reference = wall.bottomBack.elevation + REVET_EMBED_DEPTH_M;
|
||||
return {
|
||||
offset:
|
||||
wall.points[2].offset +
|
||||
wall.outward * REVET_LEAN_RATIO * (wall.topJoint.elevation - reference),
|
||||
wall.points[2].offset + wall.outward * wall.lean * (wall.topJoint.elevation - reference),
|
||||
elevation: reference,
|
||||
};
|
||||
};
|
||||
@@ -527,6 +551,7 @@ export function computeCulvertLayout(
|
||||
},
|
||||
outletWallSpec.material,
|
||||
outletWallSpec.form,
|
||||
outletWallSpec.lean,
|
||||
);
|
||||
// 관 하단선은 옮겨진 벽의 **전면 기준선 교차점**을 지나야 한다(사용자 ①).
|
||||
const face = outletWall ? outletPipeEnd(outletWall) : outletWallAnchor;
|
||||
@@ -599,9 +624,18 @@ export function computeCulvertLayout(
|
||||
)
|
||||
: null;
|
||||
|
||||
// 다단 벽 기울기 — 같은 측점·같은 설치 측 규칙으로 형태·높이마다 표준경사(B08 은 다단을 안 셈).
|
||||
const extraLean = (form: string, pureHeightM: number): number =>
|
||||
leanOf({
|
||||
form,
|
||||
height_m: pureHeightM,
|
||||
section_mode: section.design?.section_mode,
|
||||
side: culvert.hidden_pipe ? culvert.inlet.face_side : null,
|
||||
});
|
||||
// 성토부선 + 다단 기슭막이(2026-08-22 — 보호공 삭제, 윗면 선만 성토부선으로).
|
||||
// 유출측과 **집수정 계류측**이 같은 체계를 쓴다: 5m 넘으면 다단을 둘 수 있다.
|
||||
const extras = buildExtrasAt(outletWall ? pipeCorners.outlet.bottom : null, {
|
||||
const outletStart = outletWall ? pipeCorners.outlet.bottom : null;
|
||||
const outletInput = {
|
||||
startBottomElevation: outletWall?.bottomBack.elevation ?? 0,
|
||||
outward: outletInfo.outward,
|
||||
groundAt,
|
||||
@@ -609,23 +643,47 @@ export function computeCulvertLayout(
|
||||
adjusts: (revetShift?.extras ?? []).map(adjustOf),
|
||||
ownerForm: outletWallSpec.form,
|
||||
equalize: equalizeExtras === true,
|
||||
});
|
||||
leanFor: extraLean,
|
||||
};
|
||||
const extras = buildExtrasAt(outletStart, outletInput);
|
||||
// 독립 기슭막이(관 숨김)는 집수정이 없어 이 채널이 **유입측 벽의 성토부선·다단**이다 —
|
||||
// 유출측(`extras`)과 같은 시작점(관 하단 꼭짓점)·같은 `bextra` 키(2026-08-29 사용자).
|
||||
const basinExtras = buildExtrasAt(
|
||||
culvert.hidden_pipe ? (inletWall ? pipeCorners.inlet.bottom : null) : basinFillStart,
|
||||
{
|
||||
startBottomElevation: culvert.hidden_pipe
|
||||
? (inletWall?.bottomBack.elevation ?? 0)
|
||||
: basinFillBottom,
|
||||
outward: inletInfo.outward,
|
||||
groundAt,
|
||||
limitOffset: inletInfo.limit,
|
||||
adjusts: (revetShift?.basinExtras ?? []).map(adjustOf),
|
||||
ownerForm: inletWallSpec.form,
|
||||
const basinStart = culvert.hidden_pipe
|
||||
? inletWall
|
||||
? pipeCorners.inlet.bottom
|
||||
: null
|
||||
: basinFillStart;
|
||||
const basinInput = {
|
||||
startBottomElevation: culvert.hidden_pipe
|
||||
? (inletWall?.bottomBack.elevation ?? 0)
|
||||
: basinFillBottom,
|
||||
outward: inletInfo.outward,
|
||||
groundAt,
|
||||
limitOffset: inletInfo.limit,
|
||||
adjusts: (revetShift?.basinExtras ?? []).map(adjustOf),
|
||||
ownerForm: inletWallSpec.form,
|
||||
equalize: false,
|
||||
leanFor: extraLean,
|
||||
};
|
||||
const basinExtras = buildExtrasAt(basinStart, basinInput);
|
||||
/** ⭐ 면적용 다단 — **미확정 단(높이 안 적음)부터 아래는 없는 것**으로 봄(2026-09-14 브레인 판정
|
||||
* 「미확정 벽은 성토도 안 깎음 · 벽 금액과 성토 감소가 함께」). 그림은 그대로 다 그림.
|
||||
* B08 도 첫 미확정 단 아래는 미확정으로 셈(`facility_structures`) — 두 곳이 같은 선에서 끊김. */
|
||||
const confirmedOnly = (
|
||||
built: OutletExtrasResult,
|
||||
start: OffsetPoint | null,
|
||||
input: typeof outletInput,
|
||||
): OutletExtrasResult => {
|
||||
const firstUnset = built.appliedAdjusts.findIndex((applied) => applied.h == null);
|
||||
if (firstUnset < 0) return built;
|
||||
return buildExtrasAt(start, {
|
||||
...input,
|
||||
adjusts: input.adjusts.slice(0, firstUnset),
|
||||
equalize: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
const extrasForArea = confirmedOnly(extras, outletStart, outletInput);
|
||||
const basinExtrasForArea = confirmedOnly(basinExtras, basinStart, basinInput);
|
||||
|
||||
// ── 성토 사면 구간 확정. 벽 자리가 굳은 뒤에 물매를 역산해야 관 길이 맞춤(벽 이동)이
|
||||
// 접점을 다시 깨뜨리지 않는다 — 종전 어긋남의 직접 원인이 이 순서였다.
|
||||
@@ -665,8 +723,8 @@ export function computeCulvertLayout(
|
||||
trimMinSlope = { points: [...(trimMinSlope?.points ?? []), ...points] };
|
||||
}
|
||||
};
|
||||
extendTrimSlope(drawnFillPoints(extras), outletInfo.outward);
|
||||
extendTrimSlope(drawnFillPoints(basinExtras), inletInfo.outward);
|
||||
extendTrimSlope(drawnFillPoints(extrasForArea), outletInfo.outward);
|
||||
extendTrimSlope(drawnFillPoints(basinExtrasForArea), inletInfo.outward);
|
||||
|
||||
const outletWallFinal = walls.find((wall) => wall.role === "outlet") ?? null;
|
||||
const outletSlope = outletWallFinal ? slopeOf(outletWallFinal) : null;
|
||||
|
||||
@@ -23,9 +23,10 @@ import type {
|
||||
} from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import { slopedCrossing } from "./B06_Section_UI_Cross_Culvert_Extra";
|
||||
|
||||
/** 기슭막이 하단선 폭(m) — 사다리꼴 밑변. 자리 기준(하단선 중점) 환산에 쓴다. */
|
||||
export function fillWallBaseWidth(height: number): number {
|
||||
return REVET_THICKNESS_M * 1.5 + REVET_LEAN_RATIO * height;
|
||||
/** 기슭막이 하단선 폭(m) — 사다리꼴 밑변. 자리 기준(하단선 중점) 환산에 쓴다.
|
||||
* `lean` = 그 벽의 전면 기울기(표준경사 · `revetWallSpec().lean`). */
|
||||
export function fillWallBaseWidth(height: number, lean: number = REVET_LEAN_RATIO): number {
|
||||
return REVET_THICKNESS_M * 1.5 + lean * height;
|
||||
}
|
||||
|
||||
/** 유효 지반 샘플 → offset 오름차순 보간기. 범위 밖은 끝값 클램프, 샘플 없으면 null. */
|
||||
@@ -252,6 +253,7 @@ export function minShoulderWallOffset(
|
||||
baseAt: (offset: number) => number,
|
||||
toeOffset: number,
|
||||
limitOffset: number,
|
||||
lean: number = REVET_LEAN_RATIO,
|
||||
): number | null {
|
||||
const span = (limitOffset - edge.offset_m) * outward;
|
||||
if (!(span > 0)) return null;
|
||||
@@ -260,7 +262,7 @@ export function minShoulderWallOffset(
|
||||
const offset = edge.offset_m + outward * ((span * i) / steps);
|
||||
const rise = edge.elevation_m - (baseAt(offset) + height);
|
||||
if (rise < FILL_MIN_RISE_M) continue;
|
||||
const joint = offset - outward * (fillWallBaseWidth(height) / 2 - REVET_TRAP_TOP_M);
|
||||
const joint = offset - outward * (fillWallBaseWidth(height, lean) / 2 - REVET_TRAP_TOP_M);
|
||||
if (((joint - edge.offset_m) * outward) / rise < FILL_SLOPE_RATIO_MIN) continue;
|
||||
if ((offset - toeOffset) * outward > 1e-6) {
|
||||
const toeRise = edge.elevation_m - (baseAt(toeOffset) + height);
|
||||
@@ -296,13 +298,14 @@ export function solveWallVertical(
|
||||
groundBase: number,
|
||||
minHeight: number,
|
||||
limitHeight: number,
|
||||
lean: number = REVET_LEAN_RATIO,
|
||||
): WallVertical {
|
||||
const gridUp = (value: number): number =>
|
||||
Math.ceil(value / REVET_HEIGHT_STEP_M - 1e-9) * REVET_HEIGHT_STEP_M;
|
||||
let height = minHeight;
|
||||
let desiredTop = groundBase + minHeight;
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
const joint = anchorOffset - outward * (fillWallBaseWidth(height) / 2 - REVET_TRAP_TOP_M);
|
||||
const joint = anchorOffset - outward * (fillWallBaseWidth(height, lean) / 2 - REVET_TRAP_TOP_M);
|
||||
const run = Math.max(0, (joint - edge.offset_m) * outward);
|
||||
desiredTop = edge.elevation_m - run / FILL_SLOPE_RATIO_MIN;
|
||||
const next = Math.min(Math.max(gridUp(desiredTop - groundBase), minHeight), limitHeight);
|
||||
@@ -438,6 +441,8 @@ export function outletReceivable(input: {
|
||||
toeOffset: number;
|
||||
limitOffset: number;
|
||||
groundAt: (offset: number) => number;
|
||||
/** 유출 벽 전면 기울기(표준경사). */
|
||||
lean?: number;
|
||||
}): boolean {
|
||||
const invertAt = (offset: number): number =>
|
||||
Math.min(input.groundAt(offset), input.inletElevation);
|
||||
@@ -449,6 +454,7 @@ export function outletReceivable(input: {
|
||||
invertAt,
|
||||
input.toeOffset,
|
||||
input.limitOffset,
|
||||
input.lean,
|
||||
) ?? input.toeOffset;
|
||||
const invert = invertAt(auto);
|
||||
if (invert >= input.groundAt(auto) - 0.01) return true;
|
||||
@@ -486,6 +492,7 @@ export function placeInletWall(input: {
|
||||
height: number;
|
||||
toeOffset: number;
|
||||
limitOffset: number;
|
||||
lean?: number;
|
||||
};
|
||||
/** 매몰-무교차 자리 금지(유출 벽 규칙) — 독립 기슭막이는 유입도 같은 규칙을 탄다. */
|
||||
requireCrossing?: boolean;
|
||||
|
||||
@@ -80,6 +80,8 @@ export interface WallLayout {
|
||||
shiftFloorM?: number;
|
||||
/** 재질(메/찰/콘크리트) — 높이 한계를 정한다(2026-08-22 사용자). */
|
||||
material: RevetMaterial;
|
||||
/** 전면 기울기 1:n 의 n — 품셈 13-4-4 [주]⑪ 표준경사(`wallLeanRatio`, B08 과 한 벌 · 2026-09-14). */
|
||||
lean: number;
|
||||
outward: number;
|
||||
/** 합성 단면(하부 사다리꼴 + 상부 평행사변형) 꼭짓점 — 데이터 좌표. */
|
||||
points: OffsetPoint[];
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface FordPavementSpec {
|
||||
depth_m: number | null;
|
||||
/** 유입 → 유출 바닥 경사(%). 비우면 노면 횡단경사를 쓴다. */
|
||||
slope_pct: number | null;
|
||||
/** 정본에 안 적혀 기본값으로 그린 칸 「이름 값」 — 카드 머리에 「기본값(미확정)」. */
|
||||
defaulted?: string[];
|
||||
}
|
||||
|
||||
interface Edge {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Cross_Lean.ts
|
||||
* 횡단도 벽 전면 기울기 — 품셈 13-4-4 [주]⑪ 표준경사 표(`resources/data_masonry/masonry_slope_*`)를
|
||||
* 읽는 자리 하나(2026-09-14 B5 · B08 과 한 벌). 표 읽기 식은 `common_util_masonry_slope.ts`(파이썬 짝).
|
||||
*
|
||||
* 따로 뗀 까닭 — 표(JSON)를 가져오는 모듈을 상수 파일에 두면 그 상수를 쓰는 기하 조각을 Node 로
|
||||
* 단독 컴파일해 돌리는 시험들이 표 경로를 못 찾아 깨짐. 기하 조각은 기울기를 **값으로 받고**,
|
||||
* 표를 읽는 것은 이 파일을 부르는 층(배관 세트·독립·C군 벽 배치)만.
|
||||
* ⚠ 파일 판이 바뀌면 이 import 도 옮길 것 — 시험 `test_b06_wall_lean_table` 이 최신 판을 대조.
|
||||
* ========================================================================== */
|
||||
|
||||
import { type WallLeanInput, wallLeanRatio } from "../common_util/common_util_masonry_slope";
|
||||
import masonrySlope from "../resources/data_masonry/masonry_slope_2026-01-01.json";
|
||||
|
||||
/** 벽 전면 기울기 n — 표를 못 고르면 종전 0.3. */
|
||||
export function leanOf(input: WallLeanInput): number {
|
||||
return wallLeanRatio(masonrySlope, input);
|
||||
}
|
||||
@@ -23,10 +23,10 @@ import {
|
||||
PIPE_CONNECT_GRADE,
|
||||
PIPE_WALL_DEFAULT_RUN_M,
|
||||
REVET_EMBED_DEPTH_M,
|
||||
REVET_LEAN_RATIO,
|
||||
REVET_THICKNESS_M,
|
||||
revetHeightLimit,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import { leanOf } from "./B06_Section_UI_Cross_Lean";
|
||||
import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import { fillWallBaseWidth, groundInterpolator } from "./B06_Section_UI_Cross_Culvert_Solve";
|
||||
import { buildExtrasAt, slopedCrossing } from "./B06_Section_UI_Cross_Culvert_Extra";
|
||||
@@ -61,8 +61,17 @@ export interface RevetmentSpec {
|
||||
/** (구 모델) 기준 올림·좌우 이동 — 배관식 전환 뒤 자리는 조정창 4축(x·d)이 정한다. */
|
||||
lift_m?: number | null;
|
||||
shift_m?: number | null;
|
||||
/** 전면 기울기를 B08 과 같은 칸으로 가르는 값 — 성토/절토 · 전면 기울기(2026-09-14 B5). */
|
||||
face_role?: string | null;
|
||||
face_slope_ratio?: number | string | null;
|
||||
}
|
||||
|
||||
/** 표준경사 표를 읽는 벽 종류 → 형태. B08 이 돌쌓기 식으로 세는 것만(큰돌쌓기·옹벽은 종전 0.3). */
|
||||
const TABLE_FORM_BY_TYPE: Record<string, string> = {
|
||||
masonry_wet: "돌쌓기(찰)",
|
||||
masonry_dry: "돌쌓기(메)",
|
||||
};
|
||||
|
||||
/** 조정창 조작값 중 이 벽이 쓰는 축 — 배관 벽과 같은 형태(x 좌우·d 사면·h 높이). */
|
||||
export interface RevetmentAdjust {
|
||||
x?: number;
|
||||
@@ -187,9 +196,19 @@ export function computeRevetmentLayout(
|
||||
/** 기준선(근입 위)~상단 — 도형 계산은 종전대로 이 값으로 한다. */
|
||||
const height = pureHeight - REVET_EMBED_DEPTH_M;
|
||||
|
||||
// 전면 기울기 — B08 이 그 벽을 셀 때와 같은 칸(종류·요청 높이·단면유형·설치 측·사용자 칸)으로
|
||||
// 표준경사를 고름(2026-09-14 B5). 높이는 형태 한계로 자르기 **전** 값 — B08 은 저장 높이를 씀.
|
||||
const lean = leanOf({
|
||||
form: TABLE_FORM_BY_TYPE[spec.type_id] ?? (spec.type_id === "revetment" ? spec.form : null),
|
||||
height_m: requestedHeight,
|
||||
section_mode: design.section_mode,
|
||||
side: spec.side,
|
||||
face_role: spec.face_role,
|
||||
face_slope_ratio: spec.face_slope_ratio,
|
||||
});
|
||||
const roadSlope = roadSlopePerOutward(design, side, outward);
|
||||
const autoOffset =
|
||||
edge.offset_m + outward * (fillWallBaseWidth(height) / 2 - REVET_THICKNESS_M / 2);
|
||||
edge.offset_m + outward * (fillWallBaseWidth(height, lean) / 2 - REVET_THICKNESS_M / 2);
|
||||
// 자동 자리 = 성토사면(1:1.2) 위 기본 지점 — 배관 유출 벽과 같은 기준값.
|
||||
const autoRun = PIPE_WALL_DEFAULT_RUN_M;
|
||||
/**
|
||||
@@ -244,6 +263,7 @@ export function computeRevetmentLayout(
|
||||
form: spec.form,
|
||||
lengthM,
|
||||
floatGapM,
|
||||
lean,
|
||||
});
|
||||
// 넣은 좌우 이동이 **각도 하한**에 눌려 통째로 무시됐으면 그 하한을 남긴다(2026-09-07).
|
||||
// 실측 — d 2.6m 자리에서 하한이 1.0m 를 넘어 「1.0m 를 넣어도 0.00m 이동」이 났고,
|
||||
@@ -267,6 +287,13 @@ export function computeRevetmentLayout(
|
||||
// 그려졌다(2026-08-30 사용자: 형태별 모양이 누락되는 경우).
|
||||
ownerForm: spec.form,
|
||||
equalize: false,
|
||||
leanFor: (form, pureHeightM) =>
|
||||
leanOf({
|
||||
form,
|
||||
height_m: pureHeightM,
|
||||
section_mode: design.section_mode,
|
||||
side: spec.side,
|
||||
}),
|
||||
})
|
||||
: { walls: [], segments: [], appliedAdjusts: [], addable: false };
|
||||
|
||||
@@ -375,7 +402,7 @@ export function appendRevetmentOverlay(
|
||||
const keyId = index === 0 ? "own" : `own-extra${index - 1}`;
|
||||
const tooltip =
|
||||
`독립 기슭막이 ${wall.form ?? ""} H=${(wall.height + REVET_EMBED_DEPTH_M).toFixed(1)}m` +
|
||||
`(상단 = 성토선 접점, 전면 1:${REVET_LEAN_RATIO}, 높이 한계 ${revetHeightLimit(
|
||||
`(상단 = 성토선 접점, 전면 1:${wall.lean}(품셈 13-4-4 [주]⑪ 표준경사), 높이 한계 ${revetHeightLimit(
|
||||
wall.form,
|
||||
).toFixed(1)}m — 교본 7-3)` +
|
||||
(wall.floatGapM > 0.01
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface RevetWallGeometryInput {
|
||||
thickness?: number;
|
||||
floatGapM?: number;
|
||||
extraIndex?: number;
|
||||
/** 전면 기울기 1:n 의 n — 표준경사(`leanOf`). 없으면 종전 0.3. */
|
||||
lean?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,14 +67,15 @@ export interface RevetWallGeometryInput {
|
||||
export function buildRevetWallGeometry(input: RevetWallGeometryInput): WallLayout {
|
||||
const { anchor, outward, height, material, role } = input;
|
||||
const thickness = input.thickness ?? REVET_THICKNESS_M;
|
||||
const baseWidth = thickness * 1.5 + REVET_LEAN_RATIO * height;
|
||||
const lean = input.lean ?? REVET_LEAN_RATIO;
|
||||
const baseWidth = thickness * 1.5 + lean * height;
|
||||
const backOffset = anchor.offset - outward * (baseWidth / 2);
|
||||
const topJoint = backOffset + outward * (thickness / 2);
|
||||
const topElevation = anchor.elevation + height;
|
||||
const topBack: OffsetPoint = { offset: backOffset, elevation: topElevation };
|
||||
const topFront = topJoint + outward * thickness;
|
||||
const frontXAt = (elevation: number): number =>
|
||||
topFront + outward * REVET_LEAN_RATIO * (topElevation - elevation);
|
||||
topFront + outward * lean * (topElevation - elevation);
|
||||
const bottomElevation = anchor.elevation - REVET_EMBED_DEPTH_M;
|
||||
const bottomBack: OffsetPoint = { offset: backOffset, elevation: bottomElevation };
|
||||
const bottomFront: OffsetPoint = {
|
||||
@@ -90,6 +93,7 @@ export function buildRevetWallGeometry(input: RevetWallGeometryInput): WallLayou
|
||||
height,
|
||||
floatGapM: input.floatGapM ?? 0,
|
||||
material,
|
||||
lean,
|
||||
outward,
|
||||
topBack,
|
||||
topJoint: { offset: topJoint, elevation: topElevation },
|
||||
|
||||
@@ -166,6 +166,9 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
if (!owner || !culvert) return null;
|
||||
const spec = role === "outlet" ? culvert.outlet : culvert.inlet;
|
||||
const adjust = stationControls.revetOffset.adjustFor(owner, role);
|
||||
// 조작(조정창 높이)이 있을 때만 — 없으면 그려진 높이는 관경 기준 제안이라 옵션에 안 적음
|
||||
// (2026-09-14 브레인 판정 ④ 「기본값을 몰래 확정으로 안 바꿈」).
|
||||
if (adjust?.h == null) return null;
|
||||
return revetWallSpec(spec, adjust, culvert.hidden_pipe === true, culvert.diameter_m)
|
||||
.pureHeight;
|
||||
},
|
||||
@@ -180,6 +183,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
if (!owner || !culvert) return null;
|
||||
const spec = role === "outlet" ? culvert.outlet : culvert.inlet;
|
||||
const adjust = stationControls.revetOffset.adjustFor(owner, role);
|
||||
if (!adjust?.m) return null; // 조작한 형태만 — 기본 형태는 제안(판정 ④)
|
||||
return revetWallSpec(spec, adjust, culvert.hidden_pipe === true, culvert.diameter_m).form;
|
||||
},
|
||||
// 좌·우 이름표 — +offset이 좌측이고 유입 벽은 오르막(계류) 쪽에 선다.
|
||||
|
||||
@@ -36,6 +36,38 @@ function wingOptions(role: "inlet" | "outlet", patch: WingPatch): Record<string,
|
||||
return options;
|
||||
}
|
||||
|
||||
/** 옵션 키 → 「기본값(미확정)」 목록의 이름(파이썬 `_defaulted` 이름표와 같은 글). */
|
||||
const DEFAULTED_LABELS: Record<string, string> = {
|
||||
pipe_kind: "관종",
|
||||
pipe_diameter_mm: "관경",
|
||||
ford_width_m: "월류 폭",
|
||||
pipe_count: "배관 수량",
|
||||
body_width_m: "본체 폭",
|
||||
body_height_m: "본체 높이",
|
||||
};
|
||||
for (const [side, name] of [
|
||||
["in", "유입"],
|
||||
["out", "유출"],
|
||||
]) {
|
||||
DEFAULTED_LABELS[`wing_${side}`] = `${name} 날개벽`;
|
||||
DEFAULTED_LABELS[`wing_${side}_height_m`] = `${name} 날개벽 높이`;
|
||||
DEFAULTED_LABELS[`wing_${side}_length_m`] = `${name} 날개벽 길이`;
|
||||
DEFAULTED_LABELS[`wing_${side}_angle_deg`] = `${name} 날개벽 각도`;
|
||||
}
|
||||
|
||||
/** 조작으로 값이 온 칸은 「기본값(미확정)」 목록에서 뺌 — 캐시를 바로 고치는 길이라 카드 머리가
|
||||
* 옛 기본값을 계속 말하지 않게(2026-09-14 브레인 판정 ②). 항목 모양은 「이름 값」(값에 빈칸 없음). */
|
||||
function dropDefaulted(spec: { defaulted?: string[] } | undefined, options: object): void {
|
||||
if (!spec?.defaulted) return;
|
||||
const labels = Object.keys(options).flatMap((key) => DEFAULTED_LABELS[key] ?? []);
|
||||
spec.defaulted = spec.defaulted.filter(
|
||||
(item) =>
|
||||
!labels.some(
|
||||
(label) => item.startsWith(`${label} `) && !item.slice(label.length + 1).includes(" "),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** 좌측 폼이 낸 옵션에서 그 측 날개벽 조작값을 읽는다(없는 항목은 빼고 돌려준다). */
|
||||
function wingPatchFrom(patch: Record<string, number | string>, prefix: string): WingPatch {
|
||||
const num = (key: string): number | undefined => {
|
||||
@@ -157,6 +189,7 @@ export function createFordControls(deps: FordControlDeps): FordControls {
|
||||
// 월류 폭 = 구체의 도로 진행 방향 길이(`span_m`) — 백엔드 `_ford_set`과 같은 자리.
|
||||
if (patch.ford_width_m) spec.span_m = patch.ford_width_m;
|
||||
}
|
||||
dropDefaulted(spec, patch);
|
||||
deps.queuePipeOptions(chainageM, patch);
|
||||
deps.refreshCard(chainageM);
|
||||
},
|
||||
@@ -171,6 +204,7 @@ export function createFordControls(deps: FordControlDeps): FordControls {
|
||||
if (patch.angle_deg !== undefined) wing.angle_deg = patch.angle_deg;
|
||||
wing.slab_extend_m = wingSlabExtendM(wing.installed, wing.length_m, wing.angle_deg);
|
||||
}
|
||||
dropDefaulted(spec, wingOptions(role, patch));
|
||||
deps.queuePipeOptions(chainageM, wingOptions(role, patch));
|
||||
deps.refreshCard(chainageM);
|
||||
},
|
||||
@@ -349,6 +383,7 @@ export function createBoxControls(deps: FordControlDeps): {
|
||||
}
|
||||
if (patch.body_height_m) spec.inner_height_m = patch.body_height_m;
|
||||
}
|
||||
dropDefaulted(spec, patch);
|
||||
deps.queuePipeOptions(chainageM, patch as Record<string, number | string>);
|
||||
deps.refreshCard(chainageM);
|
||||
},
|
||||
@@ -364,6 +399,7 @@ export function createBoxControls(deps: FordControlDeps): {
|
||||
? Math.max((wing.length_m ?? 0) * Math.cos(((wing.angle_deg ?? 45) * Math.PI) / 180), 0)
|
||||
: 0;
|
||||
}
|
||||
dropDefaulted(spec, wingOptions(role, patch));
|
||||
deps.queuePipeOptions(chainageM, wingOptions(role, patch));
|
||||
deps.refreshCard(chainageM);
|
||||
},
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
writeStructurePick,
|
||||
} from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { FORD_BRIDGE_DEFAULT_WIDTH_M } from "@config/config_frontend";
|
||||
import {
|
||||
adjustDockRoot,
|
||||
refreshAdjustSlots,
|
||||
@@ -125,105 +126,153 @@ function coversChainage(structure: StructureInstance, chainageM: number): boolea
|
||||
return Math.abs(structureAnchorM(structure) - chainageM) < PIPE_MATCH_M;
|
||||
}
|
||||
|
||||
type Merged = Record<string, string | number>;
|
||||
|
||||
/** 그 종류의 등록부 기본값 `{키: 기본값}` — 스펙 값이 기본값에서 왔는지 가르는 데 씀. */
|
||||
function registryDefaults(types: StructureType[], typeId: string): Record<string, unknown> {
|
||||
const options = types.find((type) => type.type_id === typeId)?.options ?? [];
|
||||
return Object.fromEntries(options.map((option) => [option.key, option.default]));
|
||||
}
|
||||
|
||||
/**
|
||||
* 세월교 폼 옵션을 **지금 그려진 스펙**(`section.ford`)으로 덮는다 — 조정창 조작이
|
||||
* 이 스펙을 제자리에서 고치므로, 폼이 저장된 옵션을 붙들면 두 창이 갈린다
|
||||
* (2026-08-30 사용자: 수량(련) 칸이 비어 있고 조정창은 1련이었다).
|
||||
* 스펙 값 한 칸을 옵션에 싣는 규칙 — **저장돼 있던 칸이거나, 조작해 기본값과 달라진 칸만.**
|
||||
* 기본값에서 온 값을 실으면 페이지를 열기만 해도 임시 저장·[저장]에 기본값이 확정으로 굳음
|
||||
* (2026-09-14 브레인 판정 ④ 「기본값을 몰래 확정으로 바꾸지 않는다」). 폼은 빈 칸에 제안을 보임.
|
||||
*/
|
||||
function keepSpec(
|
||||
merged: Merged,
|
||||
stored: Merged | undefined,
|
||||
key: string,
|
||||
spec: string | number | null | undefined,
|
||||
fromDefault: unknown,
|
||||
): void {
|
||||
if (spec === null || spec === undefined) return;
|
||||
if (stored?.[key] !== undefined || String(spec) !== String(fromDefault ?? "")) merged[key] = spec;
|
||||
}
|
||||
|
||||
type WingSpec = {
|
||||
installed: boolean;
|
||||
height_m: number | null;
|
||||
length_m: number | null;
|
||||
angle_deg: number | null;
|
||||
};
|
||||
|
||||
function keepWings(
|
||||
merged: Merged,
|
||||
stored: Merged | undefined,
|
||||
defaults: Record<string, unknown>,
|
||||
wings: ReadonlyArray<readonly [WingSpec, "wing_in" | "wing_out"]>,
|
||||
): void {
|
||||
for (const [wing, prefix] of wings) {
|
||||
// 백엔드 `_wing_spec` 과 같은 채움 — 설치는 「없음」이 아니면 있음 · 치수는 기본 0/0/45.
|
||||
const install = String(defaults[prefix] ?? "") === "없음" ? "없음" : "있음";
|
||||
keepSpec(merged, stored, prefix, wing.installed ? "있음" : "없음", install);
|
||||
keepSpec(
|
||||
merged,
|
||||
stored,
|
||||
`${prefix}_height_m`,
|
||||
wing.height_m,
|
||||
defaults[`${prefix}_height_m`] ?? 0,
|
||||
);
|
||||
keepSpec(
|
||||
merged,
|
||||
stored,
|
||||
`${prefix}_length_m`,
|
||||
wing.length_m,
|
||||
defaults[`${prefix}_length_m`] ?? 0,
|
||||
);
|
||||
keepSpec(
|
||||
merged,
|
||||
stored,
|
||||
`${prefix}_angle_deg`,
|
||||
wing.angle_deg,
|
||||
defaults[`${prefix}_angle_deg`] ?? 45,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 세월교 폼 옵션 — **지금 그려진 스펙**(`section.ford`) 중 조작·저장분만 싣는다. 조정창 조작이
|
||||
* 스펙을 제자리에서 고치므로 그 값은 폼에 보여야 함(2026-08-30 사용자: 수량(련) 칸이 비고 조정창은
|
||||
* 1련) — 다만 1련이 **기본값에서 온 값이면** 제안으로만(판정 ④).
|
||||
*/
|
||||
function withFordSpec(
|
||||
options: Record<string, string | number> | undefined,
|
||||
options: Merged | undefined,
|
||||
ford: NonNullable<SectionDetailResponse["cross_sections"][number]["ford"]>,
|
||||
): Record<string, string | number> {
|
||||
const merged: Record<string, string | number> = { ...(options ?? {}) };
|
||||
if (ford.pipe_kind) merged.pipe_kind = ford.pipe_kind;
|
||||
merged.pipe_diameter_mm = Math.round(ford.diameter_m * 1000);
|
||||
merged.pipe_count = ford.pipe_count;
|
||||
merged.ford_width_m = ford.span_m;
|
||||
for (const [wing, prefix] of [
|
||||
defaults: Record<string, unknown>,
|
||||
): Merged {
|
||||
const merged: Merged = { ...(options ?? {}) };
|
||||
keepSpec(merged, options, "pipe_kind", ford.pipe_kind, defaults.pipe_kind);
|
||||
const diameter = Math.round(ford.diameter_m * 1000);
|
||||
keepSpec(merged, options, "pipe_diameter_mm", diameter, defaults.pipe_diameter_mm ?? 1000);
|
||||
const count = Number(defaults.pipe_count ?? 0);
|
||||
keepSpec(
|
||||
merged,
|
||||
options,
|
||||
"pipe_count",
|
||||
ford.pipe_count,
|
||||
count > 0 ? Math.max(Math.trunc(count), 1) : 1,
|
||||
);
|
||||
keepSpec(
|
||||
merged,
|
||||
options,
|
||||
"ford_width_m",
|
||||
ford.span_m,
|
||||
defaults.ford_width_m ?? FORD_BRIDGE_DEFAULT_WIDTH_M,
|
||||
);
|
||||
keepWings(merged, options, defaults, [
|
||||
[ford.wing_in, "wing_in"],
|
||||
[ford.wing_out, "wing_out"],
|
||||
] as const) {
|
||||
merged[prefix] = wing.installed ? "있음" : "없음";
|
||||
if (wing.height_m !== null) merged[`${prefix}_height_m`] = wing.height_m;
|
||||
if (wing.length_m !== null) merged[`${prefix}_length_m`] = wing.length_m;
|
||||
if (wing.angle_deg !== null) merged[`${prefix}_angle_deg`] = wing.angle_deg;
|
||||
}
|
||||
]);
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* BOX암거 폼 옵션을 **지금 그려진 스펙**(`section.box`)으로 덮는다 — 세월교와 같은
|
||||
* 규칙이다(2026-08-30 사용자). 본체 규격은 내공 폭·높이가 정본이고, 구체 길이·표고는
|
||||
* 조정 채널이라 여기 오지 않는다.
|
||||
*/
|
||||
/** BOX암거 폼 옵션 — 세월교와 같은 규칙(조작·저장분만). 구체 길이·표고는 조정 채널이라 여기 안 옴. */
|
||||
function withBoxSpec(
|
||||
options: Record<string, string | number> | undefined,
|
||||
options: Merged | undefined,
|
||||
box: NonNullable<SectionDetailResponse["cross_sections"][number]["box"]>,
|
||||
): Record<string, string | number> {
|
||||
const merged: Record<string, string | number> = { ...(options ?? {}) };
|
||||
merged.body_width_m = box.inner_width_m;
|
||||
merged.body_height_m = box.inner_height_m;
|
||||
for (const [wing, prefix] of [
|
||||
defaults: Record<string, unknown>,
|
||||
): Merged {
|
||||
const merged: Merged = { ...(options ?? {}) };
|
||||
keepSpec(merged, options, "body_width_m", box.inner_width_m, defaults.body_width_m ?? 2);
|
||||
keepSpec(merged, options, "body_height_m", box.inner_height_m, defaults.body_height_m ?? 2);
|
||||
keepWings(merged, options, defaults, [
|
||||
[box.wing_in, "wing_in"],
|
||||
[box.wing_out, "wing_out"],
|
||||
] as const) {
|
||||
merged[prefix] = wing.installed ? "있음" : "없음";
|
||||
if (wing.height_m !== null) merged[`${prefix}_height_m`] = wing.height_m;
|
||||
if (wing.length_m !== null) merged[`${prefix}_length_m`] = wing.length_m;
|
||||
if (wing.angle_deg !== null) merged[`${prefix}_angle_deg`] = wing.angle_deg;
|
||||
}
|
||||
]);
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* 관 지점 옵션에 **횡단 스펙 값**을 채워 넣는다 — 조정창이 보여주던 값과 폼이
|
||||
* 어긋나지 않게 한다(2026-08-29 사용자). 옵션에 이미 값이 있으면 그대로 두고,
|
||||
* 비어 있을 때만 스펙(서버 계산·저장분)에서 가져온다.
|
||||
* 관 지점 옵션에 **조정창에서 조작한 값**만 싣는다 — 조정창과 폼이 어긋나지 않게(2026-08-29 사용자).
|
||||
* ⚠ 스펙의 기본값(관종·관경·구조·벽 길이·전/후·집수정 길이)은 **안 실음** — 페이지를 열기만 해도
|
||||
* 임시 저장에 기본값이 확정으로 굳던 자리(2026-09-14 브레인 판정 ④). 폼 빈 칸은 제안을 보임.
|
||||
*/
|
||||
function withSpecDefaults(
|
||||
options: Record<string, string | number> | undefined,
|
||||
options: Merged | undefined,
|
||||
detail: SectionDetailResponse | null,
|
||||
chainageM: number,
|
||||
types: StructureType[],
|
||||
wallHeight?: (chainageM: number, role: "inlet" | "outlet") => number | null,
|
||||
wallForm?: (chainageM: number, role: "inlet" | "outlet") => string | null,
|
||||
): Record<string, string | number> | undefined {
|
||||
): Merged | undefined {
|
||||
const owner = detail?.cross_sections.find(
|
||||
(section) => Math.abs(section.chainage_m - chainageM) < PIPE_MATCH_M,
|
||||
);
|
||||
// 세월교는 폼과 조정창이 **같은 캐시**(`section.ford`)를 본다 — 어느 쪽에서 만졌든
|
||||
// 그 스펙이 지금 그려진 값이라 저장된 옵션보다 앞선다(2026-08-30 사용자: 프론트는
|
||||
// 구조물 배치 상세 UI, 로직·값은 조정창 것).
|
||||
if (owner?.ford) return withFordSpec(options, owner.ford);
|
||||
if (owner?.box) return withBoxSpec(options, owner.box);
|
||||
// 세월교·BOX는 폼과 조정창이 **같은 캐시**를 봄 — 조작한 값은 저장된 옵션보다 앞섬(2026-08-30).
|
||||
if (owner?.ford) return withFordSpec(options, owner.ford, registryDefaults(types, "ford_bridge"));
|
||||
if (owner?.box) return withBoxSpec(options, owner.box, registryDefaults(types, "box_culvert"));
|
||||
const culvert = owner?.culvert;
|
||||
if (!culvert) return options;
|
||||
const merged: Record<string, string | number> = { ...(options ?? {}) };
|
||||
const put = (key: string, value: string | number | null | undefined): void => {
|
||||
if (merged[key] === undefined && value !== null && value !== undefined) merged[key] = value;
|
||||
};
|
||||
put("pipe_kind", culvert.pipe_kind);
|
||||
put("pipe_diameter_mm", Math.round(culvert.diameter_m * 1000));
|
||||
for (const [side, prefix] of [
|
||||
[culvert.inlet, "inlet"],
|
||||
[culvert.outlet, "outlet"],
|
||||
] as const) {
|
||||
put(`${prefix}_type`, side.structure);
|
||||
// 형태·높이는 **지금 그려진 값**이 이긴다 — 조정값(조작)이 저장된 옵션보다
|
||||
// 앞서기 때문이다. 저장값을 그대로 두면 폼과 그림이 갈린다(2026-08-30 사용자:
|
||||
// 배관 유출구 기슭막이 형태가 폼과 다르게 그려졌다).
|
||||
const merged: Merged = { ...(options ?? {}) };
|
||||
for (const prefix of ["inlet", "outlet"] as const) {
|
||||
// 형태·높이는 **조작한 값**이 이긴다 — 조정값이 저장된 옵션보다 앞서기 때문(2026-08-30 사용자:
|
||||
// 배관 유출구 기슭막이 형태가 폼과 다르게 그려졌다). 조작이 없으면 부르는 쪽이 null 을 줌.
|
||||
const drawnForm = wallForm?.(chainageM, prefix);
|
||||
if (drawnForm) merged[`${prefix}_revet_form`] = drawnForm;
|
||||
else put(`${prefix}_revet_form`, side.revet_form);
|
||||
const drawnHeight = wallHeight?.(chainageM, prefix);
|
||||
if (drawnHeight != null) merged[`${prefix}_revet_height_m`] = Number(drawnHeight.toFixed(1));
|
||||
else put(`${prefix}_revet_height_m`, side.revet_height_m);
|
||||
put(`${prefix}_revet_length_m`, side.revet_length_m);
|
||||
put(`${prefix}_revet_before_m`, side.revet_before_m);
|
||||
put(`${prefix}_revet_after_m`, side.revet_after_m);
|
||||
}
|
||||
put("inlet_basin_length_m", culvert.inlet.basin_length_m);
|
||||
put("inlet_basin_before_m", culvert.inlet.basin_before_m);
|
||||
put("inlet_basin_after_m", culvert.inlet.basin_after_m);
|
||||
return merged;
|
||||
}
|
||||
|
||||
@@ -374,6 +423,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
pipe.options,
|
||||
detail,
|
||||
pipe.chainage_m,
|
||||
types,
|
||||
deps.wallHeight,
|
||||
deps.wallForm,
|
||||
),
|
||||
@@ -418,6 +468,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
hit.options,
|
||||
deps.detail?.() ?? null,
|
||||
hit.chainage_m,
|
||||
markTypes,
|
||||
deps.wallHeight,
|
||||
deps.wallForm,
|
||||
);
|
||||
|
||||
@@ -520,7 +520,9 @@ def build_mass_haul_drawing(
|
||||
if curve_entity:
|
||||
entities.append(curve_entity)
|
||||
|
||||
plan = mass_haul.get("haul_plan")
|
||||
# 그림은 잔진동을 거른 계획을 그린다 — 수량 정본(`haul_plan`)은 거르지 않아 balloon 이
|
||||
# 너무 많다(2026-09-14 브레인 ①). 옛 저장분은 그림용이 없어 `haul_plan` 을 그린다.
|
||||
plan = mass_haul.get("haul_plan_drawing") or mass_haul.get("haul_plan")
|
||||
if isinstance(plan, dict):
|
||||
entities.extend(_band_entities(drawing_id, plan, curve, mm_h))
|
||||
entities.extend(_residual_entities(drawing_id, plan, curve, interval_m, mm_h))
|
||||
|
||||
@@ -1,290 +0,0 @@
|
||||
"""표준도 **위쪽 그림** — 돌쌓기 단면(2단계, 2026-09-09).
|
||||
|
||||
실무 원본 탭이 「위 치수조서 + 아래 수량산출서」인데, 아래 표는 1단계에서 섰고 여기가 위다.
|
||||
|
||||
⚠ **기울기를 값으로 받는다 — 0.3 을 박지 않는다.**
|
||||
판정은 **B08 `face_slope_ratio()` 한 벌**을 그대로 부른다(품셈 13-4-4 [주]⑪ 표준경사 —
|
||||
직고·메찰·성절토). 사용자가 정했으면 그 값이 이긴다(확정 ⑨). 그림은 **수량이 쓴 바로
|
||||
그 값**으로 기울고, 근거 문구도 같이 받아 그림에 적는다 — 판정을 두 벌로 짜면 그림과
|
||||
수량이 갈린다.
|
||||
|
||||
⚠ **수량이 쓰는 그 상수로 그린다** — `B08_Quantity_Engine_UnitQuantity.STONE_MASONRY` 를
|
||||
직접 읽는다. 치수를 여기서 다시 적으면 **그림과 표가 갈린다**(CLAUDE.md 5장).
|
||||
|
||||
상부 두께 = 뒷길이 + 0.30 하부 두께 = 상부 + 0.30 × (H − 1.0)
|
||||
터파기 폭 = 평균두께 + 0.2 기초 0.5×0.9 (기초유) · 0.1×0.7 (기초버림)
|
||||
|
||||
⚠ 터파기 치수는 **`common_util_excavation` 한 벌**을 읽는다 — 횡단도가 쓰는 그 상수다.
|
||||
여기서 다시 적으면 두 도면이 다른 터파기를 그린다.
|
||||
|
||||
⚠ **뒷길이가 두께에 들어간다**(확정 2차 ②, 실무 구조물도 식). 뒷길이가 다르면 벽이
|
||||
두꺼워지고 그림도 그만큼 넓어진다 — 예전 식(0.45+0.10H / 0.45+0.40H)은 뒷길이를
|
||||
아예 안 봐서 35㎝ 든 45㎝ 든 같은 그림이 나왔다.
|
||||
⚠ 뒷길이를 안 정한 장은 두께를 못 낸다 — 그때는 **그리지 않는다**(0 으로 때우면
|
||||
거짓 그림이 된다).
|
||||
|
||||
⚠ **치수가 없는 것은 그리되 치수를 적지 않는다** — 막자갈(뒷채움)은 우리 식이 「입적에서
|
||||
몸통·고임돌을 뺀 것」이라 폭·높이로 정의된 도형이 아니다. 자리만 보이고 값은 표를 가리킨다.
|
||||
없는 치수를 그림에 적으면 **표와 다른 두 번째 정본**이 생긴다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
||||
_line_entity,
|
||||
_text_entity,
|
||||
polyline_entity,
|
||||
)
|
||||
|
||||
# 기울기 판정은 구조물도 엔진이 가진 한 벌 — 2026-09-13 B08 로 이관(PLAN 3장). 받아 쓰기만 함.
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureSheet import ( # noqa: F401
|
||||
SLOPE_TYPE_IDS,
|
||||
slope_of,
|
||||
)
|
||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import (
|
||||
STONE_MASONRY,
|
||||
weep_hole_spec,
|
||||
)
|
||||
from common_util.common_util_excavation import (
|
||||
WALL_BLINDING_DEPTH_M,
|
||||
WALL_BLINDING_WIDTH_M,
|
||||
WALL_FOUNDATION_DEPTH_M,
|
||||
WALL_FOUNDATION_WIDTH_M,
|
||||
WALL_TRENCH_CLEARANCE_M,
|
||||
)
|
||||
|
||||
#: 그림 축척 — 1/25(1m = 40㎜). 표(줄 높이 9㎜) 위에 얹어도 한 면에 드는 크기다.
|
||||
SCALE_MM_PER_M = 40.0
|
||||
|
||||
#: 이 그림을 그리는 종류. 옹벽·집수정처럼 단면이 다른 것은 아직 그리지 않는다.
|
||||
#: ⚠ **큰돌쌓기(`boulder_masonry`)를 뺐다**(2026-09-09). 여기 있었지만 그림이 두께를
|
||||
#: **뒷길이**에서 내는데 큰돌쌓기는 규격 축이 **직경**(`stone_cm`)이라 **영영 못 그렸다** —
|
||||
#: 목록에만 있고 결과는 늘 빈 그림이었다. 품셈 **13-6(직경) ↔ 13-4(뒷길이)** 로 축이
|
||||
#: 갈리는 자리이고 수량 쪽에서 이미 갈라 둔 그것이다(`EXPANDERS` 주석).
|
||||
#: ⇒ 「직경에서 두께를 내는 식」은 **도메인 판단이라 사용자 몫** — 계획서 4-12 답 대기 줄.
|
||||
FIGURE_TYPE_IDS: frozenset[str] = frozenset({"masonry_wet", "masonry_dry"})
|
||||
|
||||
#: 그림이 못 서는 장에 **까닭을 적는다** — 빈 자리를 그냥 두면 사용자가 「고장」으로 읽는다
|
||||
#: (2026-09-09 사용자 지시). 「무엇을 받아야 서는지」까지 적는다.
|
||||
_NO_FIGURE_REASONS: dict[str, str] = {
|
||||
"boulder_masonry": (
|
||||
"그림 없음 — 큰돌쌓기는 규격이 **직경**이라 두께를 낼 식이 아직 없습니다"
|
||||
"(뒷길이로 내는 돌쌓기 식과 축이 다름 · 사용자 확정 대기)"
|
||||
),
|
||||
"retaining_wall": "그림 없음 — 옹벽은 단면이 달라 아직 그리지 않습니다(표준도 3단계)",
|
||||
}
|
||||
_NO_FIGURE_DEFAULT = "그림 없음 — 이 형식은 아직 단면을 그리지 않습니다"
|
||||
|
||||
|
||||
def figure_reason(sheet: dict[str, Any]) -> str | None:
|
||||
"""그림이 **안 서는 까닭** 한 줄. 서는 장이면 `None`.
|
||||
|
||||
⚠ 「무엇을 받아야 서는지」까지 적는다 — 뒷길이만 고르면 서는 장과, 식 자체가 없는 장은
|
||||
사용자가 할 일이 다르다.
|
||||
"""
|
||||
type_id = str(sheet.get("type_id") or "")
|
||||
if type_id not in FIGURE_TYPE_IDS:
|
||||
return _NO_FIGURE_REASONS.get(type_id, _NO_FIGURE_DEFAULT)
|
||||
if float(sheet.get("height_m") or 0.0) <= 0:
|
||||
return "그림 없음 — 높이가 없습니다(제원에서 높이를 넣으면 그림이 섭니다)"
|
||||
if back_length_cm(sheet) is None:
|
||||
return "그림 없음 — **뒷길이를 고르면 그림이 섭니다**(두께가 뒷길이에서 나옵니다)"
|
||||
return None
|
||||
|
||||
|
||||
_LABEL_FONT = 4.0
|
||||
_DIM_FONT = 3.4
|
||||
|
||||
|
||||
def wall_thickness(height_m: float, back_cm: float) -> tuple[float, float]:
|
||||
"""(상부, 하부) 두께 — **수량이 쓰는 그 식**(`stone_masonry`)과 같은 상수를 읽는다."""
|
||||
top_t = back_cm / 100.0 + STONE_MASONRY["thickness_top_add_m"]
|
||||
bottom_t = top_t + STONE_MASONRY["thickness_slope_per_m"] * max(
|
||||
height_m - STONE_MASONRY["thickness_height_base_m"], 0.0
|
||||
)
|
||||
return top_t, bottom_t
|
||||
|
||||
|
||||
def back_length_cm(sheet: dict[str, Any]) -> float | None:
|
||||
"""장의 뒷길이(㎝). 안 정했으면 `None` — 그때는 두께를 못 내므로 그리지 않는다."""
|
||||
options = sheet.get("options") or {}
|
||||
raw = options.get("back_len_cm") or options.get("stone_back_length_cm")
|
||||
try:
|
||||
return float(raw) if raw not in (None, "") else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def section_points(
|
||||
height_m: float, slope_ratio: float, back_cm: float
|
||||
) -> list[tuple[float, float]]:
|
||||
"""벽 단면 네 점(m 단위, 밑면 앞끝이 원점). 앞면이 뒤로 `n·H` 기운다."""
|
||||
top_t, bottom_t = wall_thickness(height_m, back_cm)
|
||||
lean = slope_ratio * height_m
|
||||
return [
|
||||
(0.0, 0.0),
|
||||
(lean, height_m),
|
||||
(lean + top_t, height_m),
|
||||
(bottom_t, 0.0),
|
||||
(0.0, 0.0),
|
||||
]
|
||||
|
||||
|
||||
def build_figure(
|
||||
drawing_id: str,
|
||||
sheet: dict[str, Any],
|
||||
layer_id: str,
|
||||
origin: tuple[float, float],
|
||||
line_color: str,
|
||||
label_color: str,
|
||||
guide_color: str,
|
||||
) -> tuple[list[dict[str, Any]], float]:
|
||||
"""장 하나의 그림. `(엔티티, 그림이 차지한 높이 ㎜)` — 못 그리면 `([], 0)`."""
|
||||
if str(sheet.get("type_id") or "") not in FIGURE_TYPE_IDS:
|
||||
return [], 0.0
|
||||
height_m = float(sheet.get("height_m") or 0.0)
|
||||
if height_m <= 0:
|
||||
return [], 0.0
|
||||
|
||||
back_cm = back_length_cm(sheet)
|
||||
if back_cm is None:
|
||||
# 뒷길이가 없으면 두께를 못 낸다 — 0 으로 때우지 않고 그리지 않는다.
|
||||
return [], 0.0
|
||||
slope, slope_note = slope_of(sheet)
|
||||
scale = SCALE_MM_PER_M
|
||||
ox, oy = origin
|
||||
|
||||
def mm(point: tuple[float, float]) -> tuple[float, float]:
|
||||
return (ox + point[0] * scale, oy + point[1] * scale)
|
||||
|
||||
points = section_points(height_m, slope, back_cm)
|
||||
top_t, bottom_t = wall_thickness(height_m, back_cm)
|
||||
average_t = (top_t + bottom_t) / 2.0
|
||||
dig_width = average_t + WALL_TRENCH_CLEARANCE_M
|
||||
# 기초 몫 — 「기초유 / 기초버림」이 폭·깊이를 가른다(정본 탭 제목).
|
||||
foundation = str((sheet.get("options") or {}).get("foundation") or "")
|
||||
if foundation == "기초유":
|
||||
base_w, base_d = WALL_FOUNDATION_WIDTH_M, WALL_FOUNDATION_DEPTH_M
|
||||
elif foundation == "기초버림":
|
||||
base_w, base_d = WALL_BLINDING_WIDTH_M, WALL_BLINDING_DEPTH_M
|
||||
else:
|
||||
base_w = base_d = 0.0 # 안 정한 장은 기초를 안 그린다 — 지어내지 않는다.
|
||||
|
||||
entities: list[dict[str, Any]] = []
|
||||
wall = polyline_entity(
|
||||
f"{drawing_id}:fig:wall", [mm(p) for p in points], layer_id, line_color, width=2
|
||||
)
|
||||
if wall is not None:
|
||||
entities.append(wall)
|
||||
|
||||
# 터파기 — 우리 수량식(평균두께+0.2) × 높이 를 **그대로** 그린다. 점선.
|
||||
dig = polyline_entity(
|
||||
f"{drawing_id}:fig:dig",
|
||||
[mm(p) for p in ((0.0, 0.0), (0.0, height_m), (dig_width, height_m), (dig_width, 0.0))],
|
||||
layer_id,
|
||||
guide_color,
|
||||
dash=[4, 3],
|
||||
)
|
||||
if dig is not None:
|
||||
entities.append(dig)
|
||||
|
||||
# 기초 — 벽 밑에 놓이는 칸. 「안 정함」이면 안 그린다.
|
||||
if base_d > 0:
|
||||
base = polyline_entity(
|
||||
f"{drawing_id}:fig:base",
|
||||
[mm(p) for p in ((0.0, 0.0), (0.0, -base_d), (base_w, -base_d), (base_w, 0.0))],
|
||||
layer_id,
|
||||
guide_color,
|
||||
)
|
||||
if base is not None:
|
||||
entities.append(base)
|
||||
|
||||
# 물구멍 — 벽을 가로지르는 짧은 선 하나. 개소 간격은 글자로 적는다(면적당이라 그림에 못 씀).
|
||||
# ⚠ 값은 **그 장의 제원**에서 읽는다 — 수량표와 같은 함수를 쓴다(2026-09-13). 기본 상수를
|
||||
# 적으면 사용자가 지름·면적을 고쳐도 그림 주석만 옛 값으로 남아 표와 갈린다.
|
||||
weep_area_m2, weep_diameter_mm, _weep_basis = weep_hole_spec(sheet.get("options") or {})
|
||||
weep_y = height_m * 0.5
|
||||
entities.append(
|
||||
_line_entity(
|
||||
f"{drawing_id}:fig:weep",
|
||||
mm((slope * weep_y, weep_y)),
|
||||
mm((slope * weep_y + top_t, weep_y)),
|
||||
layer_id,
|
||||
guide_color,
|
||||
)
|
||||
)
|
||||
|
||||
labels: list[tuple[str, tuple[float, float], str]] = [
|
||||
(f"H = {height_m:g} m", (-0.28, height_m / 2.0), "right"),
|
||||
(f"상부 {top_t:.2f} m", (slope * height_m + top_t / 2.0, height_m + 0.14), "center"),
|
||||
(f"하부 {bottom_t:.2f} m", (bottom_t / 2.0, -0.30), "center"),
|
||||
(f"1 : {slope:g}", (slope * height_m / 2.0 - 0.30, height_m * 0.72), "right"),
|
||||
(
|
||||
f"터파기 폭 {dig_width:.2f} m (평균두께 {average_t:.2f} + 0.20)",
|
||||
(dig_width + 0.16, height_m * 0.92),
|
||||
"left",
|
||||
),
|
||||
(
|
||||
f"기초 {base_w:g} × {base_d:g} m ({foundation})"
|
||||
if base_d > 0
|
||||
else "기초 — 안 정함(기초유/기초버림)",
|
||||
(dig_width + 0.16, -base_d / 2.0 if base_d else 0.0),
|
||||
"left",
|
||||
),
|
||||
(
|
||||
f"물구멍 Ø{weep_diameter_mm} — {weep_area_m2:g}㎡당 1개소",
|
||||
(slope * weep_y + top_t + 0.16, weep_y),
|
||||
"left",
|
||||
),
|
||||
("막자갈(뒷채움) — 수량은 아래 표", (dig_width + 0.16, height_m * 0.55), "left"),
|
||||
]
|
||||
labels.append((f"뒷길이 ℓ₃ = {int(back_cm)}㎝", (dig_width + 0.16, height_m * 0.72), "left"))
|
||||
|
||||
for index, (text, point, align) in enumerate(labels):
|
||||
x, y = mm(point)
|
||||
entities.append(
|
||||
_text_entity(
|
||||
f"{drawing_id}:fig:label:{index}",
|
||||
text,
|
||||
x,
|
||||
y,
|
||||
layer_id,
|
||||
_DIM_FONT,
|
||||
label_color,
|
||||
align,
|
||||
)
|
||||
)
|
||||
|
||||
# 기울기 근거는 **그림 밑에** 한 줄 — 「사용자 지정」인지 「품셈 표준경사」인지 보여야 한다.
|
||||
note_x, note_y = mm((0.0, -0.62))
|
||||
entities.append(
|
||||
_text_entity(
|
||||
f"{drawing_id}:fig:slopenote",
|
||||
f"전면 기울기 — {slope_note}",
|
||||
note_x,
|
||||
note_y,
|
||||
layer_id,
|
||||
_DIM_FONT,
|
||||
label_color,
|
||||
"left",
|
||||
)
|
||||
)
|
||||
|
||||
title_x, title_y = mm((0.0, height_m + 0.52))
|
||||
entities.append(
|
||||
_text_entity(
|
||||
f"{drawing_id}:fig:title",
|
||||
f"{sheet.get('title') or '돌쌓기'} (축척 1/{int(1000 / SCALE_MM_PER_M)})",
|
||||
title_x,
|
||||
title_y,
|
||||
layer_id,
|
||||
_LABEL_FONT,
|
||||
label_color,
|
||||
"left",
|
||||
)
|
||||
)
|
||||
|
||||
used_mm = (height_m + 0.95 + base_d) * scale
|
||||
return entities, used_mm
|
||||
@@ -23,19 +23,21 @@ from typing import Any
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
||||
DRAWING_FORMAT,
|
||||
_text_entity,
|
||||
TABLE_LABEL_COLOR,
|
||||
TABLE_LINE_COLOR,
|
||||
TABLE_VALUE_COLOR,
|
||||
_format,
|
||||
_layer,
|
||||
_text_entity,
|
||||
polyline_entity,
|
||||
table_entity,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardFigure import (
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Template import usable_bbox
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureFigure import (
|
||||
SCALE_MM_PER_M as FIGURE_SCALE_MM_PER_M,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardFigure import build_figure, figure_reason
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Template import usable_bbox
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureFigure import build_figure as figure_shapes
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureFigure import figure_reason
|
||||
|
||||
STANDARD_LAYER_ID = "standard"
|
||||
|
||||
@@ -66,6 +68,44 @@ def _figure_height(sheet: dict[str, Any]) -> float:
|
||||
return (height_m + 0.95) * FIGURE_SCALE_MM_PER_M if height_m > 0 else 0.0
|
||||
|
||||
|
||||
def build_figure(
|
||||
drawing_id: str, sheet: dict[str, Any], origin: tuple[float, float]
|
||||
) -> tuple[list[dict[str, Any]], float]:
|
||||
"""B08 그림 모양 목록(m)을 CAD 엔티티(종이 ㎜)로. `(엔티티, 높이 ㎜)` — 못 그리면 `([], 0)`."""
|
||||
shapes = figure_shapes(sheet) or []
|
||||
ox, oy = origin
|
||||
scale = FIGURE_SCALE_MM_PER_M
|
||||
entities: list[dict[str, Any]] = []
|
||||
for index, shape in enumerate(shapes):
|
||||
x, y = (shape.get("at") or [0.0, 0.0])[:2]
|
||||
if shape["kind"] == "text":
|
||||
entities.append(
|
||||
_text_entity(
|
||||
f"{drawing_id}:fig:{index}",
|
||||
shape["text"],
|
||||
ox + x * scale,
|
||||
oy + y * scale,
|
||||
STANDARD_LAYER_ID,
|
||||
shape["size"] * scale,
|
||||
TABLE_LABEL_COLOR,
|
||||
shape["align"],
|
||||
)
|
||||
)
|
||||
continue
|
||||
poly = polyline_entity(
|
||||
f"{drawing_id}:fig:{index}",
|
||||
[(ox + px * scale, oy + py * scale) for px, py in shape["points"]],
|
||||
STANDARD_LAYER_ID,
|
||||
TABLE_LINE_COLOR if shape["role"] == "wall" else TABLE_VALUE_COLOR,
|
||||
dash=[4, 3] if shape["dash"] else None,
|
||||
width=2 if shape["role"] == "wall" else 1,
|
||||
)
|
||||
if poly is not None:
|
||||
entities.append(poly)
|
||||
ys = [point[1] for shape in shapes for point in shape.get("points") or [shape["at"]]]
|
||||
return entities, ((max(ys) - min(ys)) * scale if ys else 0.0)
|
||||
|
||||
|
||||
def _cell(
|
||||
text: str, color: str, *, align: str = "left", col_span: int = 1, bold: bool = False
|
||||
) -> dict[str, Any]:
|
||||
@@ -166,11 +206,7 @@ def build_standard_drawing(drawing_id: str, label: str, payload: dict[str, Any])
|
||||
figure, used = build_figure(
|
||||
drawing_id,
|
||||
sheet,
|
||||
STANDARD_LAYER_ID,
|
||||
(x0 + _FIGURE_INSET, top - _FIGURE_TOP_PAD - _figure_height(sheet)),
|
||||
TABLE_LINE_COLOR,
|
||||
TABLE_LABEL_COLOR,
|
||||
TABLE_VALUE_COLOR,
|
||||
)
|
||||
if figure:
|
||||
entities.extend(figure)
|
||||
|
||||
@@ -33,6 +33,8 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Forms import judged_form
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SOURCE = ROOT / "resources" / "data_cost_input_value" / "pum_forest_2026.json"
|
||||
OUT_DIR = ROOT / "resources" / "data_work_item_master"
|
||||
@@ -176,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 + " ")
|
||||
@@ -276,8 +290,9 @@ BASIS_RE = re.compile(r"(\d[\d,.]*)\s*(㎥|m3|㎡|m2|㏊|ha|km|㎞|m|인|본|개
|
||||
# ⚠ **「당」 또는 「단위:」 가 있어야 밑수다.** 둘 다 없으면 규격일 뿐이다 —
|
||||
# 만들다 실제로 걸렸다: `(무한궤도,0.7㎥)` 를 「0.7㎥당」으로 읽어 5건이 잘못 잡혔다.
|
||||
#: ⚠ `(단위: 인/㎡당)` 꼴 — **분모가 밑수**다. 값의 단위(인)를 밑수로 읽으면 뜻이 뒤집힌다.
|
||||
#: ⚠ 「단위:」 없이 `(인/100m당)` 으로만 적은 표가 22 곳이다(조림 5장·단끊기 5-16-1 등, 2026-09-13).
|
||||
SOURCE_BASIS_RATIO_RE = re.compile(
|
||||
r"[((]\s*단위\s*[::]\s*[^))/]+/\s*([\d,]*\.?\d*)\s*"
|
||||
r"[((]\s*(?:단위\s*[::]\s*)?[^))/::]+/\s*([\d,]*\.?\d*)\s*"
|
||||
r"(㎥|m3|㎡|m2|㏊|ha|㎞|km|m|매|본|개소|개|인|공|kg|㎏|톤|ton|주|식|대)\s*당\s*[))]"
|
||||
)
|
||||
|
||||
@@ -563,16 +578,6 @@ def capacity_formula_pending(table: dict[str, Any]) -> bool:
|
||||
return bool((keys | cells) & CAPACITY_SYMBOLS)
|
||||
|
||||
|
||||
def variant_axis(table: dict[str, Any]) -> list[str]:
|
||||
"""행이 갈리는 축 — 표의 첫 열 값들(토질·암종·규격). 값 열은 뺀다."""
|
||||
seen: list[str] = []
|
||||
for row in table.get("rows", []):
|
||||
key = norm(row[0]) if row else ""
|
||||
if key and key not in seen:
|
||||
seen.append(key)
|
||||
return seen[:24]
|
||||
|
||||
|
||||
def basis_quantity_is_grouped(quantity: float | None) -> bool:
|
||||
"""「10㎡당」처럼 **묶음 기준**인가. 1 이 아니면 곱셈이 그만큼 갈린다."""
|
||||
return quantity is not None and abs(quantity - 1.0) > 1e-9
|
||||
@@ -580,6 +585,10 @@ def basis_quantity_is_grouped(quantity: float | None) -> bool:
|
||||
|
||||
def build() -> dict[str, Any]:
|
||||
data = json.loads(SOURCE.read_text(encoding="utf-8"))
|
||||
# 사람 판정 선언(표 형태·부모 모양)은 절 번호로 적혀 판에 묶임 — 판이 다르면 멈춤(명세 17장).
|
||||
from common_util.common_util_pum_edition import require_code_edition
|
||||
|
||||
require_code_edition(data["effective_date"], "공종 마스터 빌드 — 표 형태·부모 모양 판정")
|
||||
tables = data["variables"]["pum"]["tables"]
|
||||
# 원문을 함께 연다 — 밑수가 표 밖(본문)에 있기 때문이다. 못 열면 밑수 없이 간다.
|
||||
source_lines: list[str] = []
|
||||
@@ -601,13 +610,24 @@ 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)
|
||||
# 사람이 가른 형태가 자동 판정을 이긴다 — 표마다 까닭 한 줄(미판정 표 판정, 2026-09-13).
|
||||
form, why = judged_form(table["table_id"], number) or (form, why)
|
||||
# ⚠ **본문이 정본이다.** 표 안을 긁는 쪽은 보조 — 비고에 적힌 다른 기준
|
||||
# (「10㎡당」 같은 참고 문구)을 그 표의 밑수로 잘못 물어 온다.
|
||||
# 실제로 13-3-1 이 본문 `(단위: ㎥당)` 인데 표 안 긁기가 `10㎡` 를 물어 왔다.
|
||||
@@ -628,8 +648,10 @@ def build() -> dict[str, Any]:
|
||||
basis_found += 1
|
||||
if basis_quantity_is_grouped(basis_qty):
|
||||
basis_grouped += 1
|
||||
elif form in ("requirement", "productivity"):
|
||||
elif form in ("requirement", "productivity") and not crew_table(table):
|
||||
# 참조·계수표는 곱할 값이 아니므로 목록에 넣지 않는다 — 잡음이 되면 안 본다.
|
||||
# 작업조 표도 뺌 — 밑수가 시공량 열(1단위당 = 인원 ÷ 시공량 · B09 CrewOutput)이라
|
||||
# 「N㎡당」 문구가 없어도 곱셈이 안 틀림(12-38-3 설치·해체가 여기 걸려 막혀 있었음 · 2026-09-14 301).
|
||||
basis_missing.append(
|
||||
{
|
||||
"pum_table_id": table["table_id"],
|
||||
@@ -669,7 +691,8 @@ def build() -> dict[str, Any]:
|
||||
"special_glyphs": special_glyphs(table),
|
||||
# 공식 기호가 이 표에 직접 있는가 — 없으면 앞 표에서 물려받는 모양이다.
|
||||
"capacity_formula_here": capacity_formula_pending(table),
|
||||
"variant_key": variant_axis(table),
|
||||
# 갈래 키 — 부모 모양을 단 뒤 표 읽기가 가른 대로 채움(`_Variants`). 못 가르면 빈칸.
|
||||
"variant_key": [],
|
||||
"condition_note": [norm(h) for h in table.get("headers", []) if norm(h)],
|
||||
"raw_row": table.get("rows", []), # 원문 셀 — B09 자원 축이 읽는다.
|
||||
}
|
||||
@@ -694,6 +717,10 @@ def build() -> dict[str, Any]:
|
||||
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Parents import apply_parent_modes
|
||||
|
||||
apply_parent_modes(nodes)
|
||||
# 갈래 키(명세 14장 정정) — 표 모양 가르기는 B09 표 읽기 한 벌을 그대로 돌려 씀.
|
||||
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Variants import attach_variant_keys
|
||||
|
||||
attach_variant_keys(nodes, data["effective_date"])
|
||||
|
||||
src_meta = {
|
||||
"dataset_id": data["dataset_id"],
|
||||
@@ -706,6 +733,8 @@ def build() -> dict[str, Any]:
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"dataset_id": "work_item_master_forest",
|
||||
"effective_date": data["effective_date"],
|
||||
# 공종 코드(`FP-*` 절 번호)가 묶인 품셈 판 — 자원 축·인계 줄이 이 값을 싣는다.
|
||||
"pum_edition": data["effective_date"],
|
||||
"generated_at": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"),
|
||||
"dataset_version": src_meta,
|
||||
"policy": {
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"""공종 마스터 — **사람이 가른 표 형태** (2026-09-13 축 C 1장 · 미판정 표 판정).
|
||||
|
||||
자동 판정(`detect_form`)이 못 가르거나(undetermined 76표) 잘못 가른 표만 싣는다.
|
||||
표마다 **왜 그렇게 갈랐는지 한 줄** — 원문 `(산림청고시 제2025-82호) 산림사업 표준품셈.md`.
|
||||
|
||||
⚠⚠ 뒤집히면 20배 틀림 — `productivity`(1÷값) / `requirement`(값÷밑수)는 **표 머리와 표 바로 위
|
||||
본문의 단위 표현**으로만 가른다. 행 이름·비고의 직종 이름에 끌려 읽지 않는다.
|
||||
⚠ **못 가른 표는 싣지 않는다** — undetermined 로 남는다(막갈이 깊이표·드론운반 인원 등 밑수 불명).
|
||||
⚠ `coefficient`·`reference` 로 가른 표는 일위대가 줄로 안 읽힌다(자원 축이 건너뜀) — 시공능력
|
||||
공식 계수는 형태와 상관없이 공식 읽기가 표를 직접 본다.
|
||||
⚠ 표 번호는 원천 차례로 매긴 것이라 **절 번호를 함께 대조**한다 — 어긋나면 빌드를 멈춘다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
#: 표 번호 → (절 번호, 형태, 까닭).
|
||||
FORM_JUDGMENTS: dict[str, tuple[str, str, str]] = {
|
||||
# ── 제2장 재료 기준 ─────────────────────────────────────────────
|
||||
"F0044": (
|
||||
"2-1-1",
|
||||
"reference",
|
||||
"기계별 주연료(ℓ/일·대)·잡품(%) — 행이 자원이 아니라 기계, 재료비 산출 기준",
|
||||
),
|
||||
"F0045": ("2-1-1", "reference", "위와 같은 모양(2드럼 케이블윈치)"),
|
||||
"F0047": (
|
||||
"2-1-2",
|
||||
"reference",
|
||||
"기계별 경유(ℓ/일·대)·잡품(%) — [주]③ 「1일당 소요량」, 행이 기계",
|
||||
),
|
||||
"F0048": ("2-1-3", "reference", "머리 「소요비용(원)」 — 품이 아니라 교체 1회당 금액"),
|
||||
"F0054": ("2-1-5", "requirement", "본문 「(본당)」 + 행 「소금처리량(g)」 — 본당 재료 소요량"),
|
||||
"F0063": (
|
||||
"2-1-13",
|
||||
"reference",
|
||||
"「소모율 0.00125개/hr」 — 기계 시간당 소모품, 공종 밑수가 아님",
|
||||
),
|
||||
# ── 제5장 조림·녹화 ─────────────────────────────────────────────
|
||||
"F0100": ("5-3-1", "reference", "수종별 묘목 규격(소·중·대묘) 표"),
|
||||
"F0101": ("5-3-1", "reference", "간장·근원경 규격 기준 표"),
|
||||
"F0105": ("5-3-4", "coefficient", "지주목 미설치 감률 「인력품의 10%·20%」"),
|
||||
"F0107": ("5-3-5", "coefficient", "위와 같은 감률(흉고직경 표)"),
|
||||
"F0137": ("5-24-2", "reference", "머리 「종자배합비율(%)」 조견표"),
|
||||
"F0138": ("5-24-2", "reference", "위와 같은 조견표(기타 적용지역)"),
|
||||
"F0146": ("5-29", "coefficient", "행이 K(버킷계수)·f·E·Cm — 굴착기 작업량 공식 계수"),
|
||||
# ── 제7장 수확·운재 ─────────────────────────────────────────────
|
||||
"F0187": ("7-9-2", "coefficient", "본문 「(단위 : %)」 — 집재재적·거리별 수집량 증가율"),
|
||||
"F0189": ("7-11", "productivity", "표 바로 위 「(㎥/1대, 1일)」 — 대당 일 작업량"),
|
||||
"F0190": ("7-11", "productivity", "위와 같음(최대집재거리 80m 이하)"),
|
||||
"F0194": ("7-13", "reference", "검척 산출 예시(말구직경×개수) 표"),
|
||||
"F0196": (
|
||||
"7-15-1",
|
||||
"productivity",
|
||||
"표 바로 위 「(㎥/1대, 1일)」 — 「운반량」 줄이 작업량, 「회수」 줄은 참고",
|
||||
),
|
||||
"F0197": (
|
||||
"7-15-2",
|
||||
"productivity",
|
||||
"표 바로 위 「(㎥/1대, 1일)」 — 「운재량」 줄이 작업량, [주]① 1대 1일",
|
||||
),
|
||||
"F0198": ("7-16", "productivity", "표 바로 위 「(㎥/1대, 1일)」 — 「운반량」 줄이 작업량"),
|
||||
"F0199": ("7-17", "productivity", "표 바로 위 「(㎥/1대, 1일)」 — 「운반량」 줄이 작업량"),
|
||||
"F0200": ("7-18", "productivity", "표 바로 위 「(㎥/1대, 1일)」 — 「운반량」 줄이 작업량"),
|
||||
# ── 제8장 방제 ──────────────────────────────────────────────────
|
||||
"F0205": ("8-2-1", "reference", "약제 이름·제형 목록"),
|
||||
"F0208": ("8-2-2", "reference", "선정약제 목록 + 원액 주입량(㎖/㎝) 기준 — 품 표는 따로 있음"),
|
||||
"F0212": ("8-2-3", "reference", "위와 같은 선정약제 목록"),
|
||||
"F0216": ("8-2-4", "reference", "위와 같은 선정약제 목록"),
|
||||
"F0218": ("8-2-5", "reference", "위와 같은 선정약제 목록"),
|
||||
"F0453": (
|
||||
# 2026-09-14 절 8-2-3 → 14-2 — 원본이 [주] 「…8-2-3 굴착기(2025)」…」 를 절로 읽던 것을
|
||||
# 앞 절 이어받기로 고치자 부록 앞 마지막 절(14-2)로 옮겨 감. 부록엔 절 번호가 없어 여전히
|
||||
# 제자리가 아님 — 참고표라 값에 안 닿음.
|
||||
"14-2",
|
||||
"reference",
|
||||
"〔부록 2〕 산림사업별 적용 가능 공종 목록 — 절 경계가 밀려 붙음",
|
||||
),
|
||||
"F0220": (
|
||||
"8-3",
|
||||
"productivity",
|
||||
"머리 「벌목조재 (㎥/인)·무더기 훈증 (RM/인)·그루터기 박피 (본/인)」 — 인당 작업량",
|
||||
),
|
||||
"F0221": ("8-3", "coefficient", "본문 「(단위: %)」 — 수종·직경별 지조율"),
|
||||
"F0222": ("8-3", "coefficient", "머리 「비율(수간재적의 %)」 — 근주재적 산정율"),
|
||||
"F0227": ("8-6-1", "reference", "기종별 희석배수별 약제량(L, 1회) — 살포 계산표"),
|
||||
"F0231": ("8-6-3", "reference", "희석배수별 약제소요량(㎖) — 물의 양 대비 계산표"),
|
||||
# ── 제9장 토공 — 시공능력 공식 계수 ────────────────────────────
|
||||
"F0281": ("9-15-1", "coefficient", "행이 K·f·E·Cm — [주]② Q1=3600×q×K×f×E/Cm"),
|
||||
"F0282": ("9-15-2", "coefficient", "행이 T·L·E·q — 불도저 작업량 공식 계수"),
|
||||
"F0284": ("9-16-2", "coefficient", "행이 V·W·E·D — [주]② Q=1000×V×W×E×D×f/N"),
|
||||
"F0285": ("9-16-3", "coefficient", "흡입준비·운반·흡입·대기 시간 — 살수차 사이클 시간"),
|
||||
"F0287": ("9-18", "coefficient", "행이 K·f·E·Cm — [주]① Q1=3600×q×K×f×E/Cm"),
|
||||
"F0295": ("9-22", "coefficient", "행이 K·f·E·Cm — [주]③ Q1=3600×q×K×f×E/Cm"),
|
||||
# ── 제10장 운반 ─────────────────────────────────────────────────
|
||||
"F0300": ("10-3-3", "reference", "「운반비·하차비」 적용 항목 — 값이 없는 구성 안내"),
|
||||
"F0301": ("10-4", "coefficient", "t1·t2·t3·t4 — 트레일러 운반 사이클 시간"),
|
||||
"F0308": ("10-7-4", "coefficient", "1사이클당 운반량 q — 모노레일 공식 계수"),
|
||||
"F0309": ("10-7-4", "coefficient", "본문 「(단위: 분/1회당)」 — 적재·짐부리기 시간"),
|
||||
"F0316": ("10-9-1", "reference", "규격별 1회당 표준 운반량(kg·개·㎡) — 적재 기준"),
|
||||
"F0319": ("10-11", "coefficient", "행이 L·E·q0 — [주]② q=3.2×e, Cm 공식 계수"),
|
||||
"F0320": ("10-12-1", "coefficient", "행이 K·E0·Cm — 굴착기 적재 공식 계수"),
|
||||
"F0321": ("10-12-1", "coefficient", "적재·t2·적하 시간 — 덤프트럭 사이클 공식 계수"),
|
||||
"F0324": (
|
||||
"10-13-2",
|
||||
"reference",
|
||||
"드론운반 단가산출 항목(준비시간·왕복비행시간 식) — [주] 일본 산출 예",
|
||||
),
|
||||
# ── 제11·12장 ───────────────────────────────────────────────────
|
||||
"F0328": ("11-4", "coefficient", "행이 q·k·f·E — [주]① Q=3600×q×k×f×E/Cm"),
|
||||
# 2026-09-14 절 12-2 → 12-17-1 — 원본이 펌프카 타설 [주] 「…12-2 표면 마무리를 따른다.」 를
|
||||
# 절 제목으로 읽어 12-2 에 붙어 있던 표(빌더가 앞 절 이어받기로 고침). 형태 판정은 그대로.
|
||||
"F0358": ("12-17-1", "reference", "시설유형 Type-Ⅰ~Ⅳ 적용 기준 설명"),
|
||||
"F0360": ("12-17-1", "reference", "현장조건 Type-Ⅰ~Ⅲ 적용 기준 설명"),
|
||||
"F0385": (
|
||||
"12-34-1",
|
||||
"requirement",
|
||||
"인력(인)·기계(대, Q=5.4㎥/hr) 소요량 표 — 「별도계상」 은 레미콘 자재 줄 비고일 뿐",
|
||||
),
|
||||
"F0388": ("12-34-4", "reference", "「재료비 JOINT FILLER」 항목만 — 값이 없는 구성 안내"),
|
||||
"F0390": ("12-36", "reference", "제작비·운송비·설치비 「견적처리」"),
|
||||
"F0392": ("12-38-1", "coefficient", "사용횟수별 잔존율(12회 25%·25회 10%)"),
|
||||
"F0394": ("12-38-2", "coefficient", "부자재 요율 「주자재비의 24%·52%·79%」"),
|
||||
"F0396": ("12-38-3", "reference", "설치 유형(복잡·보통·간단) 적용 시설 설명"),
|
||||
# ── 제13장 구조물 ───────────────────────────────────────────────
|
||||
"F0400": ("13-2-3", "reference", "큰돌 직경별 뒷길이 기준(㎝)"),
|
||||
"F0406": (
|
||||
"13-3",
|
||||
"reference",
|
||||
"〈돌쌓기의 개수 및 중량의 표준〉 — [주] 인력 돌쌓기·돌붙임 기준",
|
||||
),
|
||||
"F0408": (
|
||||
"13-4-3",
|
||||
"reference",
|
||||
"고임돌 소요량(㎥/㎡) — 자재 원단위표(B08 고임돌표가 씀). 품으로 세우면 자재를 두 번 셈",
|
||||
),
|
||||
"F0410": (
|
||||
"13-4-4",
|
||||
"reference",
|
||||
"[주]① 채움 콘크리트 소요량(㎥/㎡) — 자재 원단위표(B08 채움표가 씀). 품이면 두 번 셈",
|
||||
),
|
||||
"F0411": ("13-4-4", "reference", "[주]⑨ 성토 뒤채움 조약돌 두께(㎝) 기준"),
|
||||
"F0412": (
|
||||
"13-4-4",
|
||||
"reference",
|
||||
"[주]⑩ 뒷길이 표준(㎝) — 자동 판정이 「(m)」에 걸려 소요량형으로 오판",
|
||||
),
|
||||
"F0413": (
|
||||
"13-4-4",
|
||||
"reference",
|
||||
"[주]⑪ 표준경사(1:n) — 자동 판정이 소요량형으로 오판, 찰쌓기(인력)를 막음",
|
||||
),
|
||||
"F0429": ("13-10-2", "coefficient", "[주]⑥ 관입률별 계수"),
|
||||
"F0439": ("13-12-2", "coefficient", "[주]⑦ 높이·기울기 할증(%)"),
|
||||
"F0446": ("13-15-2", "reference", "작업조건(어려운·쉬운) 구분 기준"),
|
||||
}
|
||||
|
||||
|
||||
def judged_form(table_id: str, number: str | None) -> tuple[str, str] | None:
|
||||
"""사람이 가른 `(형태, 까닭)`. 없으면 `None`. 절 번호가 어긋나면 멈춘다."""
|
||||
found = FORM_JUDGMENTS.get(table_id)
|
||||
if found is None:
|
||||
return None
|
||||
section, form, why = found
|
||||
if section != number:
|
||||
raise ValueError(
|
||||
f"표 형태 판정 {table_id} 의 절 {section} 이 원천의 {number} 와 다릅니다 — "
|
||||
"원천 표 차례가 바뀌었으니 판정을 다시 대조할 것"
|
||||
)
|
||||
return form, f"사람 판정 — {why}"
|
||||
@@ -0,0 +1,85 @@
|
||||
"""공종 마스터 — **갈래 키(`variant_key`)를 표 읽기가 실제로 가른 대로** 싣는다 (명세 14장 정정).
|
||||
|
||||
종전 `variant_axis` 는 표 첫 열 값 24개만 담아, 단가표 갈래 제목 294 중 86 만 맞았다
|
||||
(2026-09-13 브레인 실측). 머리행 갈래(벌목 「5m이상~8m미만」) · 두 단 머리 · 첫 열이 자원
|
||||
이름인 표를 못 가렸다.
|
||||
|
||||
⚠ 표 모양을 여기서 **다시 판정하지 않는다** — 첫 열 · 열이 자원 · 뭉친 이름+갈래 열 · 축 셋 ·
|
||||
두 단 머리 · 작업조 · 공정 합산 · 고르기형 등 모양 가르기는 B09 표 읽기에 이미 있다.
|
||||
두 벌로 짜면 한쪽만 고쳐진다. 그 읽기를 **메모리의 이 마스터**에 돌려 표마다 가른 갈래
|
||||
이름을 원문 표기 그대로 싣는다(키 비교는 받는 쪽이 `normalize_variant_key` 로).
|
||||
⚠ 못 가른 표는 **빈칸** — 자원을 못 맞춘 표 · 계수 · 참조 · 미판정 표에 갈래를 지어내지 않는다.
|
||||
|
||||
공종마다 `variant_keys` — 표들의 갈래 + 원문이 정했으나 자원 줄로 안 서는 갈래
|
||||
(불도저 운반 공식 갈래 · 합산형 단계 잎의 암질 행 · 9-4-1 [주]① 평균 · 유로폼 12-38-2 부자재 요율 머리) · 합산형 부모는 단계 갈래.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _add(labels: list[str], seen: set[str], label: str, normalize: Any) -> None:
|
||||
key = normalize(label)
|
||||
if key and key not in seen:
|
||||
seen.add(key)
|
||||
labels.append(label)
|
||||
|
||||
|
||||
def attach_variant_keys(nodes: list[dict[str, Any]], edition: str) -> None:
|
||||
"""표마다 `variant_key`, 공종마다 `variant_keys` 를 채운다(자리에서)."""
|
||||
from B09_Estimation.B09_Estimation_Euroform import CODE as EUROFORM_CODE
|
||||
from B09_Estimation.B09_Estimation_Euroform import RATE_TABLE as EUROFORM_RATE_TABLE
|
||||
from B09_Estimation.B09_Estimation_Euroform import rates as euroform_rates
|
||||
from B09_Estimation.B09_Estimation_MachineProductivity_Dozer import extract_dozer_factors
|
||||
from B09_Estimation.B09_Estimation_ParentSteps import AVERAGE_BASIS, AVERAGE_VARIANT, rock_rows
|
||||
from B09_Estimation.B09_Estimation_ResourceAxis import build_resource_axis
|
||||
from B09_Estimation.B09_Estimation_ResourceAxis_Sources import load_combined_catalog
|
||||
from B09_Estimation.B09_Estimation_UnitPrice import normalize_variant_key as normalize
|
||||
|
||||
axis = build_resource_axis(
|
||||
{"effective_date": edition, "work_items": nodes}, load_combined_catalog()
|
||||
)
|
||||
by_table: dict[tuple[str, str], tuple[list[str], set[str]]] = {}
|
||||
for row in axis.rows:
|
||||
if row.variant:
|
||||
labels, seen = by_table.setdefault((row.work_item_code, row.pum_table_id), ([], set()))
|
||||
_add(labels, seen, row.variant, normalize)
|
||||
|
||||
steps = {
|
||||
str(step["code"])
|
||||
for node in nodes
|
||||
if node.get("parent_mode") == "sum_steps"
|
||||
for step in node.get("steps") or []
|
||||
}
|
||||
by_code: dict[str, list[str]] = {}
|
||||
for node in nodes:
|
||||
code = str(node.get("work_item_code") or "")
|
||||
keys: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for table in node.get("tables") or []:
|
||||
labels, table_seen = by_table.get((code, str(table.get("pum_table_id"))), ([], set()))
|
||||
found = extract_dozer_factors(code, table)
|
||||
for label in found if isinstance(found, dict) else {}:
|
||||
_add(labels, table_seen, label, normalize)
|
||||
if code in steps:
|
||||
for rock, _, _ in rock_rows({"tables": [table]}):
|
||||
_add(labels, table_seen, rock, normalize)
|
||||
if code == EUROFORM_CODE and table.get("pum_table_id") == EUROFORM_RATE_TABLE:
|
||||
for head in euroform_rates({"tables": [table]}): # 부자재 요율 머리 갈래
|
||||
_add(labels, table_seen, head, normalize)
|
||||
table["variant_key"] = labels
|
||||
for label in labels:
|
||||
_add(keys, seen, label, normalize)
|
||||
if code in AVERAGE_BASIS and keys:
|
||||
_add(keys, seen, AVERAGE_VARIANT, normalize)
|
||||
by_code[code] = keys
|
||||
for node in nodes:
|
||||
code = str(node.get("work_item_code") or "")
|
||||
keys = list(by_code.get(code) or [])
|
||||
if node.get("parent_mode") == "sum_steps":
|
||||
seen = {normalize(k) for k in keys}
|
||||
for step in node.get("steps") or []:
|
||||
for label in by_code.get(str(step["code"])) or []:
|
||||
_add(keys, seen, label, normalize)
|
||||
node["variant_keys"] = keys
|
||||
@@ -26,7 +26,50 @@ 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 브레인 ②)",
|
||||
)
|
||||
#: 초류종자살포 비탈면 토질 — 품셈 5-24 씨앗뿜어붙이기의 잎 둘(5-24-1 일반 · 5-24-2 마사토) 이름 그대로.
|
||||
#: 매핑 `leaf_from` 이 잎 코드로 잇고 비면 금액 없이 사유 · 제안값 없음(2026-09-14 브레인 ㉮).
|
||||
SEED_SPRAY_GROUNDS = ("일반", "마사토")
|
||||
#: 지장목제거 뿌리뽑기(9-21 제근) 굴착기 크기 — 품셈 9-21 표 갈래 0.2·0.7(무한궤도). 등급과 한 갈래.
|
||||
#: ⚠ 제안값은 칸 곁에만(스스로 안 고름 · 비면 금액 없이 사유 — 2026-09-14 브레인 판정).
|
||||
ROOT_REMOVAL_EXCAVATOR_SIZES = ("0.2", "0.7")
|
||||
ROOT_REMOVAL_EXCAVATOR_SUGGESTED = (
|
||||
"0.7",
|
||||
"산림품셈 10-12-1 [주]① 「장비는 무한궤도 굴착기(0.7㎥)를 적용한다」 — 같은 고시가 임도 표준"
|
||||
" 장비를 0.7 로 적은 자리 · 0.2㎥ 도 고를 수 있음",
|
||||
)
|
||||
#: ⭐ 2026-09-14 브레인 판정 Ⓐ — 밑수 = 초류종자살포(파종) 면적. 반영률 칸을 따로 두면 같은 값을
|
||||
#: 두 곳에서 관리하게 됨. 실무도 그렇게 움직임(거창 파종 성토 50%·절토 100% · 영월 성토 50%).
|
||||
FACE_DRESSING_NOTE = (
|
||||
"초류종자살포와 같은 면적(식재 대상) — 품셈 9-19-1 성토면 [주] 「식재를 위한 성토사면」 ·"
|
||||
" 9-19-2·3 [주]① 「비탈면 식재기반 조성에만」"
|
||||
)
|
||||
FACE_DRESSING_ZERO = "파종 면적이 0 — 식재 대상 아님(면고르기 안 셈)"
|
||||
|
||||
# 반영률 키 ↔ 집계 공종. 값은 프로젝트 설정에서 온다(기본 100 %).
|
||||
RATIO_OF_ROW = {
|
||||
@@ -72,6 +115,33 @@ class SummaryInput:
|
||||
rock_classes: list[str] = field(default_factory=list)
|
||||
rock_ratios_pct: dict[str, float] = field(default_factory=dict)
|
||||
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-14 브레인 ①).
|
||||
borrow_m3: float = 0.0
|
||||
borrow_sites: list[dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
|
||||
#: 토취(반입토) 줄 — 수량만 서고 금액은 사유(「줄은 서고 금액은 안 섬」).
|
||||
BORROW_NAME = "토취(반입토)"
|
||||
BORROW_REASON = (
|
||||
"유토곡선이 성토 부족분으로 낸 수량(다짐상태) — 토취장 거리·재료 미정이라 금액이 서지 않음"
|
||||
" · 반입 재료를 몰라 자연상태로 되돌리지 않음"
|
||||
)
|
||||
|
||||
|
||||
#: 노체다짐 줄 — ⭐ 2026-09-13 판정 「별도 줄 · 칸으로 켜고 끔 · 기본 꺼짐」.
|
||||
#: 성토 단가(포설)에 묻지 않는다 — 묻으면 왜 그 금액인지 설명이 안 되고 켜고 끈 것이 안 보인다.
|
||||
#: ⚠ [주]③ 「굴착기와 진동롤러 조합」은 작업 방식 설명 — 굴착기는 성토(포설 9-16-1) 줄이 이미 셈.
|
||||
#: 이 줄에 굴착기를 또 넣으면 두 번 셈(2026-09-14 「#조합」 철회).
|
||||
SUBGRADE_COMPACTION_SPEC = "진동롤러(자주식 10ton)"
|
||||
SUBGRADE_COMPACTION_NOTE = (
|
||||
"산출 조건에서 켬 — 품셈 9-16-2 [주]⑤ 「대규모 성토지로서 층다짐이 필요한 경우 적용」"
|
||||
" · 수량 = 성토량(포설과 같은 밑수 ㎥)"
|
||||
" · [주]③ 굴착기+진동롤러 조합 중 굴착기는 성토(포설) 줄이 셈"
|
||||
)
|
||||
|
||||
|
||||
def _ratio(source: SummaryInput, key: str) -> float:
|
||||
@@ -121,6 +191,26 @@ def build_rows(source: SummaryInput) -> list[SummaryRow]:
|
||||
|
||||
rows.append(SummaryRow(group="보정량계", amount=earth.get("adjusted_total_m3", 0.0)))
|
||||
rows.append(SummaryRow(group="성토", amount=earth.get("fill_volume_m3", 0.0)))
|
||||
if source.subgrade_compaction_enabled:
|
||||
# ⚠ 끄면 줄 자체를 안 낸다 — 빈 줄은 「세야 함」으로 읽힌다(임목파쇄와 같은 자리).
|
||||
rows.append(
|
||||
SummaryRow(
|
||||
group="노체다짐",
|
||||
spec=SUBGRADE_COMPACTION_SPEC,
|
||||
amount=earth.get("fill_volume_m3", 0.0),
|
||||
note=SUBGRADE_COMPACTION_NOTE,
|
||||
)
|
||||
)
|
||||
|
||||
if source.borrow_m3 > 0:
|
||||
sites = " · ".join(
|
||||
f"{float(s['from_m']):g}~{float(s['to_m']):g}m {float(s['volume_m3']):,.2f}㎥"
|
||||
for s in source.borrow_sites
|
||||
)
|
||||
note = f"{BORROW_REASON} · 구간 {sites}" if sites else BORROW_REASON
|
||||
rows.append(
|
||||
SummaryRow(group=BORROW_NAME, amount=source.borrow_m3, note=note, in_bill=False)
|
||||
)
|
||||
|
||||
# ── 운반 — 수단별. 무대는 집계에 오르되 내역 줄이 아니다 ────────
|
||||
rows.extend(_haul_rows(source))
|
||||
@@ -169,6 +259,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 지장목제거가 두 줄이고 **같은 면적을 나눠 쓴다**:
|
||||
@@ -225,6 +316,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} % 반영"
|
||||
@@ -251,7 +397,7 @@ def _haul_rows(source: SummaryInput) -> list[SummaryRow]:
|
||||
for item in source.haul_rows:
|
||||
key = str(item.get("equipment") or "")
|
||||
label = HAUL_LABELS.get(key, key or "운반")
|
||||
ground = str(item.get("ground") or "")
|
||||
ground = str(item.get("rock_class") or item.get("ground") or "")
|
||||
distance = item.get("average_distance_m")
|
||||
note = f"평균운반거리 {float(distance):.2f} m" if isinstance(distance, (int, float)) else ""
|
||||
# ⚠ **자연상태로 싣는다** — 「운반거리 산정은 다짐상태, 내역서 수량은 자연상태」
|
||||
|
||||
@@ -105,15 +105,17 @@ def annotate(
|
||||
component["reuse_note"] = NOTE_REUSE_MISSING
|
||||
continue
|
||||
count = entry.get("reuse_count")
|
||||
# 근거 — 대개 1-7-1 분류 · 그 공종 표가 직접 적었으면 그 표(집수정 12-15 · 2026-09-14).
|
||||
basis = str(entry.get("basis") or "품셈 1-7-1")
|
||||
for component in targets:
|
||||
component["reuse_count"] = count
|
||||
component["reuse_note"] = (
|
||||
f"품셈 1-7-1 {count}회 — 「{entry.get('matched_example')}」"
|
||||
f"{basis} {count}회 — 「{entry.get('matched_example')}」"
|
||||
if count
|
||||
else NOTE_NOT_APPLICABLE
|
||||
)
|
||||
if count:
|
||||
notes.append(f"{structure.get('name') or type_id} 거푸집 {count}회 (품셈 1-7-1)")
|
||||
notes.append(f"{structure.get('name') or type_id} 거푸집 {count}회 ({basis})")
|
||||
else:
|
||||
missing.append(type_id)
|
||||
return notes, sorted(set(missing))
|
||||
|
||||
@@ -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,8 +113,17 @@ 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,
|
||||
root_removal_excavator_m3: str | None = None,
|
||||
seed_spray_ground: str | None = None,
|
||||
priced_sheets: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""B09 가 그대로 받는 모양. 없는 표는 건너뛰되 **빈 표와 구별해 적는다**."""
|
||||
"""B09 가 그대로 받는 모양. 없는 표는 건너뛰되 **빈 표와 구별해 적는다**.
|
||||
|
||||
`priced_sheets` — 양식 일위대가로 셀 구조물도 장(`StructurePriceLink.priced_sheets`).
|
||||
그 장의 구조물은 호표 `AX-ST` 줄 하나로 가고, 일위대가가 품은 줄은 따로 안 감.
|
||||
"""
|
||||
table = mapping or load_mapping()
|
||||
work_items: list[dict[str, Any]] = []
|
||||
unmatched: list[str] = []
|
||||
@@ -121,7 +131,16 @@ 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,
|
||||
# 제근 굴착기 크기 — 등급과 「크기·등급」 한 갈래로(매핑 `variant_template` · 09-14).
|
||||
"root_removal_excavator_m3": root_removal_excavator_m3,
|
||||
# 초류종자살포 비탈면 토질 — 부모 5-24 의 잎(일반·마사토)을 고름(매핑 `leaf_from` · 09-14 ㉮).
|
||||
"seed_spray_ground": seed_spray_ground,
|
||||
}
|
||||
rows, misses = _earthwork_rows(
|
||||
summary_table, table, methods, bench_cut_depth_m, variant_inputs
|
||||
)
|
||||
@@ -135,7 +154,7 @@ def build_handoff(
|
||||
# 띠·이동에서만 운반이 만들어져 사토 잔량이 어디에도 안 실렸다.
|
||||
work_items.extend(spoil_haul_rows(haul_table, table))
|
||||
if unit_quantity_table:
|
||||
rows, misses = _structure_rows(unit_quantity_table, table)
|
||||
rows, misses = _structure_rows(unit_quantity_table, table, priced_sheets)
|
||||
work_items.extend(rows)
|
||||
unmatched.extend(misses)
|
||||
# 구조물이 낸 터파기·되메우기·잔토 — **공종 축으로 올린다.**
|
||||
@@ -145,7 +164,7 @@ def build_handoff(
|
||||
work_items.extend(rows)
|
||||
unmatched.extend(misses)
|
||||
# 기초잡석 — 버림이 선 구조물에 함께 서는 공종(확정 3차 ②). 묶음 구조물은 제외한다.
|
||||
rows, misses = rubble_base_rows(unit_quantity_table, table)
|
||||
rows, misses = rubble_base_rows(unit_quantity_table, table, priced_sheets)
|
||||
work_items.extend(rows)
|
||||
unmatched.extend(misses)
|
||||
|
||||
@@ -180,7 +199,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
|
||||
}
|
||||
@@ -208,6 +227,14 @@ def build_handoff(
|
||||
result["ratio_math_warnings"] = verify_ratio_math(result)
|
||||
result["material_code_warnings"] = verify_no_code_on_materials(result)
|
||||
result["bill_flag_warnings"] = verify_bill_flags(result)
|
||||
# ⚠ 이중계상 경계(명세 6장) — 갈 곳 칸으로 판정한 어긴 자리. B09 내역서가 여기서 멈춘다.
|
||||
from B08_Quantity.B08_Quantity_Engine_Handoff_Boundaries import (
|
||||
verify_double_count_boundaries,
|
||||
)
|
||||
|
||||
result["double_count_violations"] = verify_double_count_boundaries(
|
||||
unit_quantity_table, material_table, table, priced_sheets, work_items
|
||||
)
|
||||
# ⚠ 보내는 단위가 **품셈 밑수**와 같은가 — 받는 쪽이 그대로 곱하는 자리다(2026-09-08).
|
||||
result["basis_unit_warnings"] = verify_unit_matches_basis(
|
||||
work_items, extra=table.declared_units()
|
||||
@@ -218,6 +245,10 @@ def build_handoff(
|
||||
(unit_quantity_table or {}).get("collected_stone_deduction_m3") or 0.0
|
||||
)
|
||||
result["placing_notes"] = placing_notes
|
||||
# ⚠ 품셈 판(명세 17장) — 줄마다 코드가 본 판을 싣는다. 판이 다르면 B09 가 값을 안 씀.
|
||||
result["pum_edition"] = table.pum_edition or None
|
||||
for row in work_items:
|
||||
row.setdefault("pum_edition", table.pum_edition or None)
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""B08 → B09 인계 — **이중계상 경계를 갈 곳(`destination`) 칸으로 판정** (명세 6장 · 2026-09-13).
|
||||
|
||||
주석 경고뿐이던 규칙 셋을 수로 걸리는 검사로 세운다. 원문은 옛 계획서 8-7절(㉠·㉢)과 명세 6장.
|
||||
|
||||
① 구조물 터파기·되메우기는 **토공집계로만** — 갈 곳이 `earthwork` 인 성분을 다른 공종 줄이
|
||||
또 집으면 같은 물량이 두 공종 코드에 붙음. 터파기·되메우기가 `earthwork` 밖으로 가도 오류
|
||||
② 배합 성분(시멘트·모래·자갈)은 **B09 일위대가만 쪼갬** — 자재총괄에 뜨면 오류(㉢)
|
||||
③ 자재 할증은 **자재총괄 한 곳** — 원단위표가 이미 할증을 붙였으면 오류(㉠).
|
||||
자재총괄 합계의 할증 한 번·일위대가 재료비의 할증 전은 B09 가드가 금액 자리에서 봄
|
||||
|
||||
⚠ 새 얼개를 만들지 않는다 — 성분마다 붙은 `destination` 과, 인계 줄이 성분을
|
||||
**이름으로 집는 자리**(구조물 줄 `billing_component` · 묶음 조각 `from_components` ·
|
||||
콘크리트 타설 · 기초잡석)만 대조한다.
|
||||
⚠ 여기는 목록만 낸다. 멈추는 것은 B09 `build_bill`(`DoubleCountError`) — 가드가 모인 한 자리.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import WorkItemMapping
|
||||
from B08_Quantity.B08_Quantity_Engine_Handoff_Rows import PLACING_TARGET_NAMES
|
||||
from B08_Quantity.B08_Quantity_Engine_Handoff_Trench import RUBBLE_GROUP
|
||||
from B08_Quantity.B08_Quantity_Engine_MaterialSummary import verify_single_surcharge
|
||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import MIX_COMPONENTS
|
||||
|
||||
#: 토공집계로만 가야 하는 성분 — 전개·양식·관측 원단위가 같은 이름을 쓴다.
|
||||
EARTHWORK_ONLY = ("터파기", "되메우기")
|
||||
#: 공종 줄이 값으로 매기면 안 되는 갈 곳 — 이미 다른 자리가 세거나(토공·사토 공제) 참고값이다.
|
||||
NOT_BILLED_BY_WORK_ITEM = {
|
||||
"earthwork": "토공집계",
|
||||
"haul_deduction": "유토곡선 사토 공제",
|
||||
"reference": "참고 줄",
|
||||
}
|
||||
|
||||
|
||||
def _pickers(structure: dict[str, Any], mapping: WorkItemMapping) -> list[tuple[str, set[str]]]:
|
||||
"""이 구조물의 성분을 **이름으로 집는** 공종 줄 — (자리, 이름들). 집는 조건은 각 빌더와 같다."""
|
||||
type_id = str(structure.get("type_id") or "")
|
||||
entry = mapping.for_structure(type_id) or {}
|
||||
composite = mapping.composite_for(type_id, structure)
|
||||
found: list[tuple[str, set[str]]] = []
|
||||
if entry.get("billing_component"):
|
||||
found.append(("구조물 줄", {str(entry["billing_component"])}))
|
||||
if composite: # 걸린 묶음은 곧장 잇는 코드보다 위(빌더와 같은 조건)
|
||||
for part in composite.get("parts") or []:
|
||||
if isinstance(part, dict):
|
||||
label = f"묶음 조각 「{part.get('name') or part.get('code')}」"
|
||||
found.append((label, {str(name) for name in part.get("from_components") or []}))
|
||||
if not composite:
|
||||
# 묶음 구조물은 타설·기초잡석 줄이 건너뛴다(조각이 이미 셈) — 빌더와 같은 조건.
|
||||
found.append(("콘크리트 타설 줄", set(PLACING_TARGET_NAMES)))
|
||||
found.append(("기초잡석 줄", {RUBBLE_GROUP}))
|
||||
return found
|
||||
|
||||
|
||||
def verify_double_count_boundaries(
|
||||
unit_quantity_table: dict[str, Any] | None,
|
||||
material_table: dict[str, Any] | None,
|
||||
mapping: WorkItemMapping,
|
||||
priced_sheets: list[dict[str, Any]] | None = None,
|
||||
work_items: list[dict[str, Any]] | None = None,
|
||||
) -> list[str]:
|
||||
"""어긴 자리를 사람 말로 — 비면 경계가 지켜진 것."""
|
||||
found: list[str] = []
|
||||
for structure in (unit_quantity_table or {}).get("structures") or []:
|
||||
label = str(structure.get("name") or structure.get("type_id") or "구조물")
|
||||
destinations: dict[str, set[str]] = {}
|
||||
for component in structure.get("components") or []:
|
||||
if float(component.get("amount") or 0.0) <= 0:
|
||||
continue
|
||||
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 '(없음)'} — "
|
||||
"구조물 터파기·되메우기는 토공집계(earthwork)로만 감"
|
||||
)
|
||||
for where, names in _pickers(structure, mapping):
|
||||
for name in sorted(names):
|
||||
for destination in sorted(
|
||||
destinations.get(name, set()) & set(NOT_BILLED_BY_WORK_ITEM)
|
||||
):
|
||||
found.append(
|
||||
f"① {label} 「{name}」은 {NOT_BILLED_BY_WORK_ITEM[destination]}"
|
||||
f"({destination})로 가는데 {where}이 또 셈 — 같은 물량이 두 자리에 붙음"
|
||||
)
|
||||
for row in (material_table or {}).get("rows") or []:
|
||||
name = str(row.get("name") or "").strip()
|
||||
if name in MIX_COMPONENTS:
|
||||
found.append(
|
||||
f"② 자재총괄에 배합 성분 「{name}」 — 시멘트·모래·자갈은 B09 일위대가만 쪼갬(㉢)"
|
||||
)
|
||||
found.extend(f"③ {warning} (㉠)" for warning in verify_single_surcharge(unit_quantity_table))
|
||||
found.extend(_verify_conserved(unit_quantity_table, mapping, priced_sheets, work_items))
|
||||
return found
|
||||
|
||||
|
||||
#: 여러 구조물의 성분을 **이름으로 모아 한 줄**로 세우는 인계 줄 — (줄 이름, 모으는 성분 이름들).
|
||||
#: ⚠ 이 줄들은 구조물마다 「누구 것을 뺐나」를 안 남긴다 — 그래서 물량 보존으로 본다.
|
||||
SUMMED_ROWS = (
|
||||
(RUBBLE_GROUP, frozenset({RUBBLE_GROUP})),
|
||||
("콘크리트 타설", frozenset(PLACING_TARGET_NAMES)),
|
||||
)
|
||||
_TOLERANCE_M3 = 1e-6
|
||||
|
||||
|
||||
def _verify_conserved(
|
||||
unit_quantity_table: dict[str, Any] | None,
|
||||
mapping: WorkItemMapping,
|
||||
priced_sheets: list[dict[str, Any]] | None,
|
||||
work_items: list[dict[str, Any]] | None,
|
||||
) -> list[str]:
|
||||
"""④ 양식 호표·묶음 조각이 품은 성분을 모음 줄이 또 셈 — **물량 보존**으로 판정(2026-09-13).
|
||||
|
||||
모음 줄 + 호표가 품은 몫 + 묶음 조각 ≤ 원단위 성분 합. 넘치면 같은 물량을 두 자리에서 셈
|
||||
(검증 프로젝트 찰쌓기: 호표가 품은 기초잡석이 인계 줄로도 나가 8.3㎥ ← 실제 3.5㎥).
|
||||
⚠ 빌더마다 「무엇을 건너뛰나」를 따라 짜지 않는다 — 건너뛰기를 빠뜨린 빌더를 잡는 검사다.
|
||||
"""
|
||||
if not unit_quantity_table or work_items is None:
|
||||
return []
|
||||
covered = {
|
||||
sid: set(entry.get("covered") or [])
|
||||
for entry in priced_sheets or []
|
||||
for sid in entry.get("structure_ids") or []
|
||||
}
|
||||
found: list[str] = []
|
||||
for row_name, names in SUMMED_ROWS:
|
||||
available = held = 0.0
|
||||
for structure in unit_quantity_table.get("structures") or []:
|
||||
taken = covered.get(str(structure.get("structure_id")), set())
|
||||
for component in structure.get("components") or []:
|
||||
name = str(component.get("name") or "").strip()
|
||||
if name not in names or component.get("unit") != "㎥":
|
||||
continue
|
||||
amount = float(component.get("amount") or 0.0)
|
||||
available += amount
|
||||
held += amount if name in taken else 0.0
|
||||
in_parts = sum(
|
||||
float(part.get("quantity") or 0.0)
|
||||
for row in work_items
|
||||
for part in row.get("composite_parts") or []
|
||||
if isinstance(part, dict)
|
||||
and part.get("from_components")
|
||||
and set(part["from_components"]) <= names
|
||||
)
|
||||
summed = sum(
|
||||
float(row.get("quantity") or 0.0)
|
||||
for row in work_items
|
||||
if row.get("name") == row_name and not row.get("composite_parts")
|
||||
)
|
||||
if summed + held + in_parts > available + _TOLERANCE_M3:
|
||||
found.append(
|
||||
f"④ 「{row_name}」 줄 {summed:g}㎥ + 양식 호표가 품은 {held:g}㎥ + 묶음 조각 "
|
||||
f"{in_parts:g}㎥ 가 원단위 합 {available:g}㎥ 를 넘음 — 호표·조각이 품은 성분을"
|
||||
" 모음 줄이 또 셈"
|
||||
)
|
||||
return found
|
||||
@@ -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,15 +138,24 @@ 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` 은 **작업 갈래**다
|
||||
#: (지장목제거의 「뿌리뽑기·잡관목제거」). 갈래로 읽으면 「시공법 미지정」이라는 **틀린 사유**가
|
||||
#: 붙는다(2026-09-09 실측). 정의처는 `EarthworkSummary` 이고 여기서 그대로 가져다 쓴다.
|
||||
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import ( # noqa: E402
|
||||
BORROW_NAME,
|
||||
)
|
||||
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import ( # noqa: E402
|
||||
GROUND_SPLIT_GROUPS as GROUND_SPLIT_GROUPS,
|
||||
)
|
||||
|
||||
#: 코드 없이 **수량만** 서는 집계 줄 — 막힘 사유는 집계 비고 그대로(토취 · 2026-09-14 브레인 ①).
|
||||
SUMMARY_ONLY_GROUPS = frozenset({BORROW_NAME})
|
||||
|
||||
SLOPE_GROUPS = frozenset({"성토면다짐", "초류종자살포", "지장목제거", "층따기", "면고르기"})
|
||||
|
||||
#: 집계 합계 줄 — 내역 줄이 아니라 검산용이다.
|
||||
@@ -192,6 +207,8 @@ class WorkItemMapping:
|
||||
"""수량 줄 → 공종 마스터 코드. 못 찾으면 `None` 을 돌려주고 부른 쪽이 목록에 남긴다."""
|
||||
|
||||
effective_date: str = ""
|
||||
#: 매핑의 공종 코드가 본 품셈 판(명세 17장) — 인계 줄마다 이 값이 `pum_edition` 으로 실린다.
|
||||
pum_edition: str = ""
|
||||
earthwork: list[dict[str, Any]] = field(default_factory=list)
|
||||
haul: list[dict[str, Any]] = field(default_factory=list)
|
||||
structure: list[dict[str, Any]] = field(default_factory=list)
|
||||
@@ -260,13 +277,20 @@ class WorkItemMapping:
|
||||
return row
|
||||
return None
|
||||
|
||||
def composite_for(self, type_id: str) -> dict[str, Any] | None:
|
||||
def composite_for(
|
||||
self, type_id: str, structure: dict[str, Any] | None = None
|
||||
) -> dict[str, Any] | None:
|
||||
"""품셈에 그 이름의 공종이 없어 **여러 공종을 묶는** 자리인가.
|
||||
|
||||
빈 코드로 두면 「매핑을 못 찾은 줄」과 구별이 안 된다 — 묶음을 적어 구별한다.
|
||||
"""
|
||||
options = (structure or {}).get("options") or {}
|
||||
for row in self.composite.get("items") or []:
|
||||
if row.get("type_id") == type_id:
|
||||
# `when` — 같은 종류라도 그 제원일 때만 묶음(콘크리트 집수정만 12-15 조립 · 09-14 ⑸).
|
||||
when = row.get("when") or {}
|
||||
if row.get("type_id") == type_id and all(
|
||||
str(options.get(key) or "") == str(value) for key, value in when.items()
|
||||
):
|
||||
return row
|
||||
return None
|
||||
|
||||
@@ -284,6 +308,7 @@ def load_mapping(path: Path | None = None) -> WorkItemMapping:
|
||||
variant_aliases = [row for row in load_aliases("variant") if row["pum_edition"] == edition]
|
||||
return WorkItemMapping(
|
||||
effective_date=str(payload.get("effective_date") or ""),
|
||||
pum_edition=edition,
|
||||
earthwork=list(payload.get("earthwork") or []),
|
||||
haul=list(payload.get("haul") or []),
|
||||
structure=list(payload.get("structure") or []),
|
||||
@@ -315,10 +340,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]] = []
|
||||
@@ -328,14 +361,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] = {
|
||||
@@ -362,6 +395,30 @@ def composite_quantities(
|
||||
entry["not_ready"] = True
|
||||
entry["why"] = why
|
||||
missing.append({"code": spec.get("code"), "reason": why})
|
||||
if suffix == "formwork_reuse":
|
||||
# 12-4 사용횟수 갈래 — B08 이 성분에 단 횟수(`Formwork.annotate`) 그대로(한 벌 · 09-14).
|
||||
counts = {
|
||||
component.get("reuse_count")
|
||||
for component in structure.get("components") or []
|
||||
if str(component.get("name") or "").strip() in found
|
||||
}
|
||||
count = next(iter(counts)) if len(counts) == 1 else None
|
||||
if isinstance(count, int) and count > 0:
|
||||
entry["kind"] = f"{count}회"
|
||||
entry["kind_basis"] = next(
|
||||
(
|
||||
str(component.get("reuse_note") or "")
|
||||
for component in structure.get("components") or []
|
||||
if str(component.get("name") or "").strip() in found
|
||||
),
|
||||
"",
|
||||
)
|
||||
entry["code"] = f"{spec.get('code')}#{count}회"
|
||||
elif found:
|
||||
why = "거푸집 사용횟수가 없거나 둘 이상이라 12-4 갈래를 못 고름"
|
||||
entry["not_ready"] = True
|
||||
entry["why"] = why
|
||||
missing.append({"code": spec.get("code"), "reason": why})
|
||||
if suffix == "rebar_complexity":
|
||||
# 갈래는 원문이 정한다 — 화면·인계에 이름과 근거를 함께 실어 사람이 검증하게 한다.
|
||||
complexity, why = rebar_complexity(
|
||||
@@ -439,10 +496,12 @@ def rebar_complexity(
|
||||
continue
|
||||
if row.get("form") == form:
|
||||
if row.get("class"):
|
||||
return str(row["class"]), f"품셈 12-3 [주]① 「{row.get('matched')}」"
|
||||
basis = row.get("basis") or "품셈 12-3 [주]①"
|
||||
return str(row["class"]), f"{basis} 「{row.get('matched')}」"
|
||||
return None, str(row.get("why") or "원문 예시에 없음")
|
||||
if fallback and fallback.get("class"):
|
||||
return str(fallback["class"]), f"품셈 12-3 [주]① 「{fallback.get('matched')}」"
|
||||
basis = fallback.get("basis") or "품셈 12-3 [주]①" # 그 공종 표가 직접 적으면 그 표(12-15)
|
||||
return str(fallback["class"]), f"{basis} 「{fallback.get('matched')}」"
|
||||
from B08_Quantity.B08_Quantity_Wording import type_label
|
||||
|
||||
detail = f"({form})" if form else "(형식이 아직 입력되지 않음)"
|
||||
|
||||
@@ -7,24 +7,28 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import string
|
||||
from typing import Any
|
||||
|
||||
from B08_Quantity.B08_Quantity_Engine_BasisUnit import normalize_unit, unit_for_code
|
||||
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,
|
||||
ORIGIN_STRUCTURE,
|
||||
SLOPE_GROUPS,
|
||||
SUBTOTAL_GROUPS,
|
||||
SUMMARY_ONLY_GROUPS,
|
||||
WorkItemMapping,
|
||||
composite_quantities,
|
||||
masonry_class,
|
||||
@@ -33,6 +37,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 +61,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,7 +153,30 @@ def _earthwork_rows(
|
||||
# 보는 쪽을 고쳤으면 **다는 쪽도** 빠짐없이 달아야 한다.
|
||||
blocked_kind = mismatch[1] if mismatch else None
|
||||
blocked_reason = mismatch[2] if mismatch else ""
|
||||
if code is None and not is_subtotal:
|
||||
inputs = {**(variant_inputs or {}), "ground_class": ground}
|
||||
variant_value = (
|
||||
(entry or {}).get("variant_value")
|
||||
or inputs.get(str((entry or {}).get("variant_from") or ""))
|
||||
or None
|
||||
)
|
||||
# 입력 둘을 한 갈래 값으로 엮는 매핑(9-21 「크기·등급」 · 09-14) — 하나라도 비면 안 엮음.
|
||||
template = str((entry or {}).get("variant_template") or "")
|
||||
needs = [name for _, name, _, _ in string.Formatter().parse(template) if name]
|
||||
template_ready = all(inputs.get(name) for name in needs)
|
||||
if template and template_ready:
|
||||
variant_value = template.format(**inputs)
|
||||
leaf_from = str((entry or {}).get("leaf_from") or "")
|
||||
leaf_codes = (entry or {}).get("leaf_codes") or {}
|
||||
leaf = leaf_codes.get(inputs.get(leaf_from)) if leaf_from else None
|
||||
code = leaf or code
|
||||
missing = not leaf if leaf_from else (not variant_value or not template_ready)
|
||||
# 매핑이 「이 칸이 비면 못 고름」이라 적은 갈래 — 금액 없이 입력 사유(면고르기 · 09-14 Ⓒ).
|
||||
if code and missing 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 group in SUMMARY_ONLY_GROUPS: # 토취 — 코드 없이 수량만 · 사유는 집계 비고(브레인 ①)
|
||||
blocked_kind, blocked_reason = BLOCKED_INPUT_MISSING, str(row.get("note") or "")
|
||||
elif 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)
|
||||
if blocked_kind is None:
|
||||
@@ -178,11 +210,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": "",
|
||||
@@ -218,10 +246,13 @@ def _haul_rows(
|
||||
# ⚠ 코드가 없으면 **줄에 막힘 표시를 단다**(2026-09-09) — 목록에만 실으면 줄 단위로
|
||||
# 보는 쪽이 「멀쩡한 줄」로 읽어 금액이 조용히 빠진다(도자운반·덤프운반이 그랬다).
|
||||
# ⚠ `in_bill` 이 False 인 무대 줄은 **막힌 것이 아니다** — 품에 포함이라 안 세우는 것.
|
||||
haul_blocked = BLOCKED_UNIT_DATA_MISSING if (code is None and in_bill) else None
|
||||
haul_blocked_reason = (
|
||||
f"운반({equipment})의 품셈 공종을 아직 못 이었습니다" if haul_blocked else ""
|
||||
no_code = code is None and in_bill # 암 줄 막힘은 운반표가 구성비로 가르며 단 것(㉱)
|
||||
haul_blocked = (
|
||||
BLOCKED_UNIT_DATA_MISSING if no_code else in_bill and row.get("blocked_kind") or None
|
||||
)
|
||||
haul_blocked_reason = str(in_bill and row.get("blocked_reason") or "")
|
||||
if no_code:
|
||||
haul_blocked_reason = f"운반({equipment})의 품셈 공종을 아직 못 이었습니다"
|
||||
# ⚠⚠ **내역서 수량은 자연상태다** — 유토곡선은 다짐상태로 쌓고(운반거리를 그 기준으로
|
||||
# 재야 맞는다) 내역에 오르는 수량은 되돌린 값이다(`config_system_design` 5-4-3
|
||||
# 「운반거리 산정 시 모든 수량은 다짐상태로 환산해 계산하고, **내역서에 적용하는
|
||||
@@ -246,7 +277,9 @@ def _haul_rows(
|
||||
{
|
||||
"work_item_code": code,
|
||||
"name": f"{equipment} 운반",
|
||||
"spec": str(row.get("ground") or ""),
|
||||
"spec": " · ".join(
|
||||
dict.fromkeys(filter(None, (row.get("rock_class"), row.get("ground"))))
|
||||
),
|
||||
"unit": "㎥",
|
||||
"quantity": quantity,
|
||||
# 운반에는 반영률 개념이 없다 — 그래서 `None` 이다(0 이 아니다).
|
||||
@@ -281,9 +314,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]:
|
||||
@@ -342,13 +394,19 @@ def _component_billing(
|
||||
|
||||
|
||||
def _structure_rows(
|
||||
unit_quantity_table: dict[str, Any], mapping: WorkItemMapping
|
||||
unit_quantity_table: dict[str, Any],
|
||||
mapping: WorkItemMapping,
|
||||
priced_sheets: list[dict[str, Any]] | None = None,
|
||||
) -> tuple[list[dict[str, Any]], list[str]]:
|
||||
"""구조물 줄 — **작업 공종 하나**로 선다.
|
||||
|
||||
⚠ 전개 성분(터파기·야면석·모르터)은 여기 오지 않는다. 구조물 한 기가 내역 한 줄이고,
|
||||
그 전개는 토공 합산·자재총괄·일위대가로 갈린다(8-2 이중계상 함정).
|
||||
⭐ 양식 일위대가로 셀 장(`priced_sheets`)에 든 구조물은 **호표 `AX-ST` 줄**로 감(PLAN 6장 ②).
|
||||
"""
|
||||
from B08_Quantity.B08_Quantity_Engine_StructurePriceLink import by_structure, template_row
|
||||
|
||||
priced = by_structure(priced_sheets or [])
|
||||
rows: list[dict[str, Any]] = []
|
||||
unmatched: list[str] = []
|
||||
for structure in unit_quantity_table.get("structures") or []:
|
||||
@@ -372,6 +430,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 {})
|
||||
|
||||
@@ -379,7 +442,11 @@ def _structure_rows(
|
||||
# m 수량에 곱해 **2.6배** 금액이 섰다(2026-09-08 실증). 어느 성분으로 세는지는
|
||||
# 매핑이 말한다(`billing_component`) — 코드가 짐작하지 않는다.
|
||||
billing = _component_billing(structure, entry)
|
||||
composite = mapping.composite_for(type_id) if code is None else None
|
||||
composite = mapping.composite_for(type_id, structure)
|
||||
if composite:
|
||||
code = None # 묶음이 걸리면 곧장 잇는 코드보다 위(콘크리트 집수정 12-15 조립 · 09-14)
|
||||
if composite.get("outside_note"):
|
||||
class_basis = " · ".join(p for p in (class_basis, composite["outside_note"]) if p)
|
||||
kind = structure_kind(structure) if composite else None
|
||||
parts: list[dict[str, Any]] | None = None
|
||||
parts_missing: list[dict[str, Any]] = []
|
||||
@@ -396,11 +463,24 @@ def _structure_rows(
|
||||
# 갈래 축과 **저장 제원 원본값**. 가공하지 않는다.
|
||||
variant_axis = str(entry.get("variant_axis") or "") or None
|
||||
variant_value = (structure.get("options") or {}).get(variant_axis) if variant_axis else None
|
||||
if variant_axis == "back_len_cm":
|
||||
# 뒷길이만은 **수량표·구조물도가 실제로 쓴 값**과 출처를 싣는다 — 안 고른 벽(관 유입·
|
||||
# 유출부 기슭막이는 늘 빔)도 [주]⑩ 하한으로 물량이 섰는데 갈래만 못 골랐음(09-14).
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureFigure import back_length_cm
|
||||
|
||||
back, default_note = back_length_cm(structure)
|
||||
variant_value = str(back) if variant_value in (None, "") else variant_value
|
||||
used = f"뒷길이 {back}㎝ — {default_note or '저장 제원'}"
|
||||
class_basis = " · ".join(part for part in (class_basis, used) if part)
|
||||
secondary_axes = [
|
||||
{"axis": axis, "value": (structure.get("options") or {}).get(axis)}
|
||||
for axis in entry.get("secondary_axes") or []
|
||||
]
|
||||
if code is None and composite is None:
|
||||
# 양식 일위대가로 셀 구조물 — 막힘은 일위대가 표가 가림(전개 갈래 사유는 안 셈).
|
||||
sheet_entry = priced.get(str(structure.get("structure_id")))
|
||||
if sheet_entry is not None:
|
||||
pass
|
||||
elif code is None and composite is None:
|
||||
unmatched.append(f"{wording_type_label(type_id)} — 품셈 공종을 아직 못 이었습니다")
|
||||
elif entry.get("class_from") in ("back_length", "bond") and class_key is None:
|
||||
unmatched.append(f"{wording_type_label(type_id)} — {class_basis}")
|
||||
@@ -432,6 +512,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,
|
||||
@@ -476,6 +563,8 @@ def _structure_rows(
|
||||
"origin": ORIGIN_STRUCTURE,
|
||||
}
|
||||
)
|
||||
if sheet_entry is not None and not unconfirmed:
|
||||
rows[-1] = template_row(rows[-1], structure, sheet_entry)
|
||||
return rows, unmatched
|
||||
|
||||
|
||||
@@ -524,8 +613,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 ""), structure) or structure.get(
|
||||
"unconfirmed"
|
||||
):
|
||||
continue # 기본값으로 선 구조물도 — 금액에 안 듦
|
||||
# ⚠ 버림은 **따로 센다** — 실무 내역이 「레미콘타설(장비) **무근,버림**」으로 갈라
|
||||
# 적는다(봉화 제50호표, 2026-09-09 데스크탑 보조 확인). 같은 공종·같은 단가라
|
||||
# 금액은 안 움직이고 **이름만 맞추는 것**이다.
|
||||
|
||||
@@ -48,6 +48,8 @@ def _preparation_rows(preparation_table: dict[str, Any]) -> list[dict[str, Any]]
|
||||
status = str(row.get("status") or "")
|
||||
amount = row.get("amount")
|
||||
ready = status == PREP_READY and amount is not None
|
||||
# 값은 서도 **내역 줄이 아닌 줄**(경비 — 임목폐기물 처리). 막힘이 아니라 가는 곳이 다름.
|
||||
billable = ready and row.get("bill") is not False
|
||||
rows.append(
|
||||
{
|
||||
"work_item_code": row.get("work_item_code"),
|
||||
@@ -88,8 +90,14 @@ def _preparation_rows(preparation_table: dict[str, Any]) -> list[dict[str, Any]]
|
||||
"spec_class_basis": str(row.get("reason") or ""),
|
||||
"composite_not_ready": None,
|
||||
# 값이 없는 줄은 **내역에 세우지 않는다** — 0 원 줄을 만들면 더 나쁘다.
|
||||
"in_bill": ready,
|
||||
"in_bill_reason": "" if ready else str(row.get("reason") or status),
|
||||
"in_bill": billable,
|
||||
"in_bill_reason": (
|
||||
""
|
||||
if billable
|
||||
else str(row.get("bill_reason") or "")
|
||||
if ready
|
||||
else str(row.get("reason") or status)
|
||||
),
|
||||
"origin": ORIGIN_PREPARATION,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -23,6 +23,8 @@ from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import (
|
||||
ORIGIN_HAUL,
|
||||
WorkItemMapping,
|
||||
)
|
||||
from B08_Quantity.B08_Quantity_Engine_Handoff_Rows import LOADING_EQUIPMENT
|
||||
from B08_Quantity.B08_Quantity_Engine_RockSplit import split_amounts
|
||||
|
||||
SPOIL_NAME = "사토 운반"
|
||||
#: ⚠ **사토장 사면 물량은 안 센다**(2026-09-09 세 창 확인). 교본 6장 3절은 「완료 구간 비탈면을
|
||||
@@ -97,20 +99,28 @@ def spoil_haul_rows(
|
||||
unknown = float(spoil.get("ground_unknown_m3") or 0.0)
|
||||
if by_ground or unknown > 0:
|
||||
rows: list[dict[str, Any]] = []
|
||||
for label, amount in sorted(by_ground.items()):
|
||||
leg = by_distance.get(label)
|
||||
# 암은 운반표와 같은 구성비 몫으로(자리표시 리핑암을 값으로 안 씀 · ㉱ `RockSplit`).
|
||||
shares = (haul_table or {}).get("rock_shares")
|
||||
for label, amount, share_reason, rock_class, source in split_amounts(by_ground, shares):
|
||||
leg = by_distance.get(source)
|
||||
leg_blocked = blocked if leg is None else False
|
||||
rows.append(
|
||||
_spoil_row(
|
||||
code,
|
||||
amount,
|
||||
distance if leg is None else leg,
|
||||
leg_blocked,
|
||||
reason if leg_blocked else "",
|
||||
leg_blocked or bool(share_reason),
|
||||
share_reason or (reason if leg_blocked else ""),
|
||||
note,
|
||||
ground=label,
|
||||
extra=" · ".join(
|
||||
(DISTANCE_FROM_SETTING if leg is None else DISTANCE_FROM_SITE, state_note)
|
||||
part
|
||||
for part in (
|
||||
f"암 갈래 {rock_class}" if rock_class else "",
|
||||
DISTANCE_FROM_SETTING if leg is None else DISTANCE_FROM_SITE,
|
||||
state_note,
|
||||
)
|
||||
if part
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -127,8 +137,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 +200,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": "",
|
||||
|
||||
@@ -149,17 +149,29 @@ def _row(**fields: Any) -> dict[str, Any]:
|
||||
|
||||
|
||||
def rubble_base_rows(
|
||||
unit_quantity_table: dict[str, Any], mapping: WorkItemMapping
|
||||
unit_quantity_table: dict[str, Any],
|
||||
mapping: WorkItemMapping,
|
||||
priced_sheets: list[dict[str, Any]] | None = None,
|
||||
) -> tuple[list[dict[str, Any]], list[str]]:
|
||||
"""기초잡석(품셈 12-25) — 구조물 전개가 낸 성분을 **한 줄로** 올린다.
|
||||
|
||||
⚠ **묶음으로 서는 구조물은 건너뛴다** — 옹벽 묶음에 이미 `FP-12-25` 조각이 있어
|
||||
여기서 또 세우면 같은 잡석을 두 번 센다(타설 줄에서 이미 겪은 자리).
|
||||
⚠ **양식 일위대가가 기초잡석을 품은 구조물도 건너뛴다**(PLAN 6장 ② 브레인 판정) —
|
||||
호표 `AX-ST` 금액에 이미 들어 있음.
|
||||
"""
|
||||
from B08_Quantity.B08_Quantity_Engine_StructurePriceLink import by_structure
|
||||
|
||||
priced = by_structure(priced_sheets or [])
|
||||
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 "")):
|
||||
if mapping.composite_for(str(structure.get("type_id") or ""), structure) 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
|
||||
for component in structure.get("components") or []:
|
||||
if str(component.get("name") or "") != RUBBLE_GROUP:
|
||||
@@ -209,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 []:
|
||||
@@ -231,7 +247,8 @@ def structure_earthwork_rows(
|
||||
backfill += amount
|
||||
elif name == "잔토처리":
|
||||
spoil += amount
|
||||
if not any(
|
||||
# 성분이 아예 없는 구조물(세월교 등 산출식 없음)은 제 줄이 이미 사유로 막힘 — 여기 안 적음.
|
||||
if structure.get("components") and not any(
|
||||
str(component.get("name") or "") == "터파기"
|
||||
and float(component.get("amount") or 0.0) > 0
|
||||
for component in structure.get("components") or []
|
||||
|
||||
@@ -97,6 +97,8 @@ def haul_inputs(unit_quantity_table: dict[str, Any] | None) -> dict[str, Any]:
|
||||
stone_by_ground: dict[str, float] = {}
|
||||
stone_unknown = 0.0
|
||||
for structure in unit_quantity_table.get("structures") or []:
|
||||
if structure.get("unconfirmed"):
|
||||
continue # 기본값으로 선 구조물 — 잔토·채집석이 사토 운반 금액으로 번지지 않게
|
||||
amount = 0.0
|
||||
stone = 0.0
|
||||
for component in structure.get("components") or []:
|
||||
|
||||
@@ -254,6 +254,8 @@ def summary_input_rows(table: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
{
|
||||
"equipment": row["equipment"],
|
||||
"ground": row["ground"],
|
||||
# 암 갈래(구성비로 가른 줄 · `RockSplit`) — 집계표 공종 칸이 흙깎기와 같은 이름을 씀.
|
||||
"rock_class": row.get("rock_class"),
|
||||
"volume_m3": row["volume_m3"],
|
||||
"volume_basis": row.get("volume_basis") or "compacted",
|
||||
"natural_m3": row.get("natural_m3"),
|
||||
@@ -264,6 +266,22 @@ def summary_input_rows(table: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
]
|
||||
|
||||
|
||||
def borrow_of(plan: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
"""토취(반입토) — 유토곡선 잔량의 부족분과 구간(2026-09-14 브레인 ①). 없으면 `None`.
|
||||
|
||||
⚠ **다짐상태** 그대로 — 반입 재료를 몰라 자연상태로 되돌릴 계수가 없음(지어내지 않음).
|
||||
"""
|
||||
volume = float((plan or {}).get("borrow_m3") or 0.0)
|
||||
if volume <= 0:
|
||||
return None
|
||||
sites = [
|
||||
{"from_m": r.get("from_m"), "to_m": r.get("to_m"), "volume_m3": float(r["volume_m3"])}
|
||||
for r in (plan or {}).get("residuals") or []
|
||||
if r.get("kind") == "borrow" and float(r.get("volume_m3") or 0.0) > 0
|
||||
]
|
||||
return {"volume_m3": volume, "volume_basis": "compacted", "sites": sites}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class HaulCheck:
|
||||
"""검산 — 무대를 안 내면 이 대조가 죽는다(PLAN 8-7 ㉡)."""
|
||||
|
||||
@@ -92,6 +92,10 @@ SURCHARGE_RATE_UNAVAILABLE = "rate_unavailable" # 자재는 있는데 율을
|
||||
|
||||
NOTE_RATE_MISSING = "할증률 미확보"
|
||||
NOTE_INCLUDED = "품셈에 할증 포함 — 중복 적용 안 함"
|
||||
#: ⚠ **안 정한 자재는 사급으로 선다**(2026-09-14 브레인 판정 ⓐ — 「기본 사급 · 관급은 프로젝트
|
||||
#: 설정이 정함」 · 품셈 제1장 「관계규정이나 계약조건에 따른다」). 종전 `unknown` 은 어느 합계에도
|
||||
#: 안 들어 자재비가 통째로 빠져 있었음. 명시적으로 `unknown` 을 고른 줄은 그대로 미정.
|
||||
NOTE_SUPPLY_DEFAULT = "관급구분 기본 사급 — 관급이면 산출 조건에서 고름"
|
||||
|
||||
|
||||
def _latest_dataset_path(directory: Path | None = None) -> Path | None:
|
||||
@@ -109,6 +113,9 @@ class SurchargeTable:
|
||||
effective_date: str = ""
|
||||
source: dict[str, Any] = field(default_factory=dict)
|
||||
rates: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
#: 율이 없는 **까닭** — 자재 → 사유. 보류(조건이 다름)와 이름 없음을 줄에 드러냄.
|
||||
#: ⚠ 「할증률 미확보」만 적으면 「아직 안 찾음」과 「찾았으나 일부러 안 걺」이 안 갈림.
|
||||
missing_reasons: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
def rate_for(self, material: str, condition: str | None = None) -> tuple[float | None, str]:
|
||||
"""(할증률 %, 근거). 표에 없으면 `(None, "")` — **0 을 돌려주지 않는다.**"""
|
||||
@@ -138,10 +145,25 @@ def load_surcharge_table(path: Path | None = None) -> SurchargeTable:
|
||||
for row in payload.get("rates_pct", [])
|
||||
if row.get("material") is not None and row.get("rate") is not None
|
||||
}
|
||||
reasons = {
|
||||
str(item.get("material")): (
|
||||
f"조건이 다름 — 사용자 확정 후 옮길 것 · 원문 「{item.get('listed_condition')}」"
|
||||
f" {item.get('rate')}% · {item.get('why_not_applied')}"
|
||||
)
|
||||
for item in (payload.get("candidates_pending_user") or {}).get("items") or []
|
||||
if item.get("material")
|
||||
}
|
||||
reasons.update(
|
||||
{
|
||||
str(name): "할증률표에 이름 없음(산림·건설 품셈 1-3-1 확인) — 임의로 안 정함"
|
||||
for name in (payload.get("not_found") or {}).get("materials") or []
|
||||
}
|
||||
)
|
||||
return SurchargeTable(
|
||||
effective_date=str(payload.get("effective_date") or ""),
|
||||
source=payload.get("source") or {},
|
||||
rates=rates,
|
||||
missing_reasons=reasons,
|
||||
)
|
||||
|
||||
|
||||
@@ -154,7 +176,10 @@ class MaterialRow:
|
||||
spec: str = "" # 규격 — 돌 종류·콘크리트 강도처럼 같은 이름을 가르는 값(명세 13장 Ⓒ)
|
||||
net_amount: float = 0.0 # 순수량 — 할증 전
|
||||
surcharge_pct: float | None = None # None = 미확보
|
||||
surcharge_cap: float | None = None # 할증률표 값(1-3-1 「이내」의 상한) — 고른 값의 천장
|
||||
supply: str = SUPPLY_UNKNOWN
|
||||
#: 산출 조건에 안 적어 **기본 사급**으로 선 줄 — 그 사실을 비고에 적음.
|
||||
supply_default: bool = False
|
||||
install_by: str | None = None # 관급 줄에만 — 사급은 비워 둔다
|
||||
surcharge_included: bool = False # 품셈에 이미 포함
|
||||
basis: str = ""
|
||||
@@ -187,6 +212,8 @@ class MaterialRow:
|
||||
parts.append(self.basis)
|
||||
if self.supply == SUPPLY_OWNER and self.install_by is None:
|
||||
parts.append(NOTE_INSTALL_BY_MISSING)
|
||||
if self.supply_default:
|
||||
parts.append(NOTE_SUPPLY_DEFAULT)
|
||||
return " · ".join(parts)
|
||||
|
||||
|
||||
@@ -286,6 +313,23 @@ def _supply_setting(supply: dict[str, Any], row: MaterialRow) -> Any:
|
||||
return None
|
||||
|
||||
|
||||
def _chosen_rate(cap: float | None, raw: Any) -> tuple[float | None, str]:
|
||||
"""(쓸 할증률, 사유) — 고른 값은 0 ~ 표값(상한)만. 표에 없으면 고른 값도 안 씀(임의 금지)."""
|
||||
if raw is None or str(raw).strip() == "" or cap is None:
|
||||
return cap, ""
|
||||
try:
|
||||
value = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return cap, f"고른 할증률 「{raw}」 을 수로 못 읽어 표값 {cap:g}% 씀"
|
||||
if not 0 <= value <= cap:
|
||||
return cap, f"고른 할증률 {value:g}% 는 표값 {cap:g}% 이내가 아니라 안 씀(1-3-1 「이내」)"
|
||||
return value, f"산출 조건에서 고른 값 {value:g}% (표 상한 {cap:g}% 이내)"
|
||||
|
||||
|
||||
#: 건너뛴 까닭 칸 — 치수가 없어 기본값으로 선 구조물(구조물 수로 셈).
|
||||
UNCONFIRMED_SKIP = "미확정(기본값으로 선 구조물)"
|
||||
|
||||
|
||||
def _collect(
|
||||
unit_quantity_table: dict[str, Any],
|
||||
) -> tuple[dict[tuple[str, str, str], MaterialRow], dict[str, int]]:
|
||||
@@ -293,6 +337,10 @@ def _collect(
|
||||
rows: dict[tuple[str, str, str], MaterialRow] = {}
|
||||
skipped: dict[str, int] = {}
|
||||
for structure in unit_quantity_table.get("structures", []):
|
||||
if structure.get("unconfirmed"):
|
||||
# 기본값으로 선 구조물 — 자재에 안 듦(2026-09-14 브레인 판정 「없으면 없다고 보이기」).
|
||||
skipped[UNCONFIRMED_SKIP] = skipped.get(UNCONFIRMED_SKIP, 0) + 1
|
||||
continue
|
||||
label = str(structure.get("name") or structure.get("type_id") or "")
|
||||
for component in structure.get("components", []):
|
||||
destination = str(component.get("destination") or "") or "(없음)"
|
||||
@@ -312,11 +360,16 @@ def build_table(
|
||||
supply_map: dict[str, Any] | None = None,
|
||||
extra_materials: Iterable[dict[str, Any]] = (),
|
||||
concrete_placing_method: str | None = None,
|
||||
surcharge_overrides: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""화면·API 가 그대로 쓰는 모양.
|
||||
|
||||
`extra_materials` 는 구조물 전개 밖에서 오는 자재(떼·초류종자 등 사면 계열)를 받는 자리다.
|
||||
모양은 원단위 성분과 같다(`name`·`unit`·`amount`·`destination`).
|
||||
|
||||
`surcharge_overrides` — `{이름 규격: %}` 산출 조건에서 고른 할증률. 품셈 1-3-1
|
||||
「표의 값 **이내**」라 **0 ~ 표값만** 받음 · 표에 없는 자재·할증 포함 줄엔 안 걺
|
||||
(넘으면 안 쓰고 사유).
|
||||
"""
|
||||
table = surcharge_table or load_surcharge_table()
|
||||
rows, skipped = _collect(unit_quantity_table)
|
||||
@@ -333,9 +386,14 @@ def build_table(
|
||||
missing_rate: list[str] = []
|
||||
missing_supply: list[str] = []
|
||||
missing_install_by: list[str] = []
|
||||
chosen_rates = surcharge_overrides or {}
|
||||
for row in rows.values():
|
||||
name = row.name
|
||||
row.supply, row.install_by = _supply_of(_supply_setting(supply, row))
|
||||
setting = _supply_setting(supply, row)
|
||||
row.supply, row.install_by = (
|
||||
_supply_of(setting) if setting is not None else (SUPPLY_CONTRACTOR, None)
|
||||
)
|
||||
row.supply_default = setting is None
|
||||
if row.supply == SUPPLY_UNKNOWN:
|
||||
missing_supply.append(row.supply_key)
|
||||
# ⚠ 설치 주체는 관급 줄에만 묻는다. 사급은 애초에 대상액 밖이라 비워 두는 것이 맞다.
|
||||
@@ -345,8 +403,11 @@ def build_table(
|
||||
continue
|
||||
lookup, alias_note = surcharge_lookup_name(name, concrete_placing_method)
|
||||
rate, basis = table.rate_for(lookup)
|
||||
row.surcharge_cap = rate
|
||||
rate, chosen_note = _chosen_rate(rate, chosen_rates.get(row.supply_key))
|
||||
row.surcharge_pct = rate
|
||||
row.basis = " · ".join(part for part in (basis, alias_note) if part)
|
||||
reason = table.missing_reasons.get(lookup, "") if rate is None else ""
|
||||
row.basis = " · ".join(part for part in (basis, alias_note, chosen_note, reason) if part)
|
||||
if rate is None:
|
||||
missing_rate.append(name)
|
||||
|
||||
@@ -371,6 +432,8 @@ def build_table(
|
||||
"unit": row.unit,
|
||||
"net_amount": row.net_amount,
|
||||
"surcharge_pct": row.surcharge_pct,
|
||||
# 고를 수 있는 천장(표값) — `None` 이면 칸이 안 섬(표에 없거나 할증 포함 줄).
|
||||
"surcharge_cap": None if row.surcharge_included else row.surcharge_cap,
|
||||
"total_amount": row.total_amount,
|
||||
"supply": row.supply,
|
||||
"supply_label": SUPPLY_LABELS.get(row.supply, row.supply),
|
||||
|
||||
@@ -151,6 +151,109 @@ def billing_of(
|
||||
return str(found.get("unit") or "개소"), scale
|
||||
|
||||
|
||||
#: 단면으로 터파기를 세운 줄의 사유 — 표 사유·구조물도 그림이 **같은 말**(브레인 판정 2026-09-14).
|
||||
SECTION_TRENCH_NOTE = (
|
||||
"터파기는 기초 폭 + 양쪽 여유 · 옆면 1:0.5(판 깊이 기준)"
|
||||
" — 벽 높이 몫은 지반선이 제원에 없어 제외"
|
||||
)
|
||||
|
||||
|
||||
def earthwork_missing_note(entry: dict[str, Any]) -> str | None:
|
||||
"""관측 줄에 토공 줄이 없고 단면으로도 못 세우면 한 줄 — 표 사유·구조물도 그림이 같은 말."""
|
||||
if any(item.get("destination") == "earthwork" for item in entry.get("components") or []):
|
||||
return None
|
||||
if (entry.get("section") or {}).get("trench"):
|
||||
return SECTION_TRENCH_NOTE
|
||||
return "⚠ 터파기·되메우기·잔토가 안 섬 — 관측 원단위 표에 그 줄이 없음(값을 지어내지 않음)"
|
||||
|
||||
|
||||
def weep_component(
|
||||
entry: dict[str, Any], options: dict[str, Any], scale: float, scale_note: str
|
||||
) -> dict[str, Any] | None:
|
||||
"""물구멍관 — 단면이 있는 관측 줄은 **실무 식에 제원 칸을 넣어** 다시 셈(붙박이 값 대신).
|
||||
|
||||
식 `벽 높이 ÷ 개소당 벽면적 × 관 길이`(소광리 「옹벽2.0」 T31). 관 길이는 실무 관측값이라
|
||||
`basis_kind` 는 그대로 `observed`. 규격 `Ø지름` 을 달아 돌쌓기 물구멍관과 (이름+규격)으로 합침.
|
||||
"""
|
||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_StoneSpec import weep_hole_spec
|
||||
|
||||
section = entry.get("section") or {}
|
||||
weep = section.get("weep")
|
||||
if not weep:
|
||||
return None
|
||||
area, diameter, spec_basis = weep_hole_spec(options)
|
||||
height, length = section["wall"]["height_m"], weep["pipe_length_m"]
|
||||
per_unit = height / area * length
|
||||
return {
|
||||
"name": "물구멍관",
|
||||
"unit": "m",
|
||||
"amount": per_unit * scale,
|
||||
"destination": "material",
|
||||
"basis": (
|
||||
f"관측 원단위 식(소광리 「옹벽2.0」 T31) 벽 높이 {height:g} ÷ {area:g}㎡/개소"
|
||||
f" × 관 {length:g} m = {per_unit:.3f}/{entry.get('unit')} × {scale_note}"
|
||||
f" · 관 길이는 관측값 · {spec_basis}"
|
||||
),
|
||||
"basis_kind": BASIS_OBSERVED,
|
||||
"source": str(entry.get("source") or ""),
|
||||
"spec": f"Ø{diameter}",
|
||||
}
|
||||
|
||||
|
||||
def section_trench_components(
|
||||
entry: dict[str, Any], rubble_thickness_m: float, scale: float
|
||||
) -> tuple[list[dict[str, Any]], float] | None:
|
||||
"""단면이 있는 관측 줄의 터파기·되메우기·잔토(`destination: earthwork`)와 파는 깊이(m).
|
||||
|
||||
⚠ 같은 파일 식생옹벽블럭 기초 터파기 식 — 밑폭 = 기초 폭 + 양쪽 여유 0.3 · 옆면 1:0.5
|
||||
(윗폭 = 밑폭 + 0.5 × 판 깊이 × 2) · 깊이 = 기초 + 버림 + 기초잡석 · 벽 높이 몫은 안 셈
|
||||
(지반선이 제원에 없음). 브레인 판정 2026-09-14 — 처음 수직에서 1:0.5 로 고침.
|
||||
⚠ 되메우기 = 터파기 − 그 안에 든 것(기초·전단키 콘크리트 + 버림 + 기초잡석) — 잔토 = 든 것.
|
||||
"""
|
||||
section = entry.get("section") or {}
|
||||
trench = section.get("trench")
|
||||
if not trench:
|
||||
return None
|
||||
footing, key, blinding = section["footing"], section["key"], section["blinding"]
|
||||
clearance, slope = trench["clearance_m"], trench.get("side_slope", 0.0)
|
||||
width = footing["width_m"] + 2 * clearance
|
||||
depth = footing["thickness_m"] + blinding["thickness_m"] + rubble_thickness_m
|
||||
top_width = width + 2 * slope * depth
|
||||
key_below = max(key["depth_m"] - blinding["thickness_m"] - rubble_thickness_m, 0.0)
|
||||
excavation = (width + top_width) / 2 * depth + key["width_m"] * key_below
|
||||
layer_width = footing["width_m"] + 2 * blinding["overhang_m"] - key["width_m"]
|
||||
filled = (
|
||||
footing["width_m"] * footing["thickness_m"]
|
||||
+ key["width_m"] * key["depth_m"]
|
||||
+ layer_width * (blinding["thickness_m"] + rubble_thickness_m)
|
||||
)
|
||||
backfill = excavation - filled
|
||||
basis = (
|
||||
f"(밑폭 {width:.2f}(기초 {footing['width_m']:g} + 여유 {clearance:g}×2)"
|
||||
f" + 윗폭 {top_width:.2f}(1:{slope:g})) ÷ 2 × 깊이 {depth:.2f}"
|
||||
f"(기초 {footing['thickness_m']:g} + 버림 {blinding['thickness_m']:g}"
|
||||
f" + 기초잡석 {rubble_thickness_m:g})"
|
||||
f" · {SECTION_TRENCH_NOTE} · 여유·기울기는 소광리 식생옹벽블럭 기초 터파기"
|
||||
)
|
||||
rows = [
|
||||
("터파기", excavation, basis),
|
||||
("되메우기", backfill, f"터파기 − 든 것 {filled:.3f}(기초·전단키 + 버림 + 기초잡석)"),
|
||||
("잔토처리", filled, "터파기 − 되메우기"),
|
||||
]
|
||||
return [
|
||||
{
|
||||
"name": name,
|
||||
"unit": "㎥",
|
||||
"amount": amount * scale,
|
||||
"destination": "earthwork",
|
||||
"basis": f"{text} = {amount:.3f}/{entry.get('unit')}",
|
||||
"basis_kind": BASIS_DERIVED,
|
||||
"source": str(entry.get("source") or ""),
|
||||
}
|
||||
for name, amount, text in rows
|
||||
], depth
|
||||
|
||||
|
||||
def expand_observed(
|
||||
type_id: str,
|
||||
spec: dict[str, Any],
|
||||
@@ -170,23 +273,53 @@ def expand_observed(
|
||||
if scale <= 0:
|
||||
from B08_Quantity.B08_Quantity_Wording import type_label
|
||||
|
||||
return [], [f"{type_label(type_id)}의 연장·면적이 0 이라 물량을 내지 않았습니다"]
|
||||
where = (
|
||||
"면적(월류 폭 × 포장 길이)이 0 — 두 칸을 적으면 섬"
|
||||
if found.get("unit") == "㎡"
|
||||
else "연장이 0 — 연장을 적으면 섬"
|
||||
)
|
||||
return [], [f"{type_label(type_id)}의 {where}"]
|
||||
|
||||
source_key = str(found.get("source") or "")
|
||||
options = structure.get("options") or {}
|
||||
components: list[dict[str, Any]] = []
|
||||
blocked: list[str] = []
|
||||
for item in found.get("components") or []:
|
||||
note = str(item.get("basis_note") or "")
|
||||
if not item.get("destination"):
|
||||
# ⛔ 갈 곳 기본값 금지(명세 13장) — 종전 `or "material"` 은 줄이 늘 때 조용히 자재로 샘.
|
||||
blocked.append(
|
||||
f"「{item['name']}」 관측 원단위 줄에 갈 곳이 없어 물량을 어느 축에도 안 보냄"
|
||||
" — 표(`structure_unit_observed`)에 적을 것(명세 13장 갈 곳 기본값 금지)"
|
||||
)
|
||||
continue
|
||||
weep = (
|
||||
weep_component(found, options, scale, scale_note)
|
||||
if item["name"] == "물구멍관"
|
||||
else None
|
||||
)
|
||||
if weep is not None:
|
||||
components.append(weep)
|
||||
continue
|
||||
components.append(
|
||||
{
|
||||
"name": item["name"],
|
||||
"unit": item["unit"],
|
||||
"amount": float(item["amount"]) * scale,
|
||||
"destination": item.get("destination") or "material",
|
||||
"destination": item["destination"],
|
||||
# 근거를 값 옆에 붙인다 — 관측값임을 화면·인계에서 바로 알아야 한다.
|
||||
"basis": f"관측 원단위 {item['amount']:g}/{found.get('unit')} × {scale_note}"
|
||||
+ (f" ({note})" if note else ""),
|
||||
"basis_kind": BASIS_OBSERVED,
|
||||
"source": source_key,
|
||||
# 규격은 이름과 따로(명세 13장 Ⓒ) — 「이형철근」 + 「D13」(2026-09-14 가르기).
|
||||
"spec": str(item.get("spec") or ""),
|
||||
}
|
||||
)
|
||||
return components, [f"관측 원단위 적용 — {found.get('source_note') or source_key}"]
|
||||
notes = [f"관측 원단위 적용 — {found.get('source_note') or source_key}", *blocked]
|
||||
# 줄에 붙은 사유(원문끼리 다름 등) — 구조물도 그림도 같은 글자를 씀.
|
||||
notes.extend(str(note) for note in found.get("notes") or [])
|
||||
earthwork_note = earthwork_missing_note(found)
|
||||
if earthwork_note:
|
||||
notes.append(earthwork_note)
|
||||
return components, notes
|
||||
|
||||
@@ -16,9 +16,9 @@
|
||||
기슭막이가 같은 파일에 있다. `pipe_diameter_mm` 유무로 가르면 **관경 미지정 관을 놓친다**
|
||||
(실측: `5601e828` 11점 중 2점이 `facility: ford_bridge`).
|
||||
|
||||
⚠ **유출·유입부 기슭막이는 여기서 세지 않는다.**
|
||||
관 옵션(`outlet_revet_*`)이 정본이고 구조물 목록에서는 빠졌다(2026-08-28 이관).
|
||||
구조물 쪽으로 또 세면 이중계상이다.
|
||||
⚠ **유출·유입부 기슭막이는 관 줄에 넣지 않는다 — `facility_structures` 가 원단위 전개로 한 번 셈.**
|
||||
관 옵션(`outlet_revet_*`)이 정본이다(2026-08-28 이관). 관 부설(품셈 12-11 m당: 관·기초콘크리트·
|
||||
거푸집)에 기슭막이 몫이 없어 겹치지 않는다. 2026-09-14 까지는 **어느 쪽도 안 세고 있었다**(A1).
|
||||
|
||||
⚠ **터파기·되메우기를 관 줄에 붙이지 않는다.**
|
||||
관 부설과 굴착이 각각 오면 **같은 굴착을 두 번** 센다(B09 ㉡ 가드와 같은 자리).
|
||||
@@ -26,6 +26,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
#: 배관으로 보는 `facility` 값. 그 밖(BOX암거·물넘이·세월교·독립 기슭막이)은 관이 아니다.
|
||||
@@ -192,3 +195,347 @@ def build_rows(
|
||||
"ready_count": sum(1 for row in rows if row["in_bill"]),
|
||||
"length_total_m": round(sum(row["quantity"] for row in rows if row["in_bill"]), 3),
|
||||
}
|
||||
|
||||
|
||||
INLET_BASIN = "집수정"
|
||||
#: 기슭막이 벽 칸 — 관·독립 기슭막이 모두 `{쪽}_revet_{칸}` · 독립의 옛 저장분은 `{칸}`(B06 `_revet_side`).
|
||||
REVET_FIELDS = (
|
||||
("form", "형태"),
|
||||
("height_m", "높이"),
|
||||
("length_m", "길이"),
|
||||
("before_m", ""),
|
||||
("after_m", ""),
|
||||
)
|
||||
#: (저장 채널, 관에 딸린 줄 이름 꼬리, 독립 기슭막이 줄 이름 꼬리)
|
||||
REVET_ROLES = (
|
||||
("inlet", "유입부 기슭막이", "유입 칸 벽"),
|
||||
("outlet", "유출부 기슭막이", "유출 칸 벽"),
|
||||
)
|
||||
#: 관 벽 높이를 안 적었으면 **횡단도가 그리는 관경 기준 최소 높이**(B06 `pipe_wall_min_height_m`,
|
||||
#: TS 짝 `pipeWallMinPureHeight`)로 섬 — 등록부·구조물도·횡단도가 한 값(2026-09-14 브레인 판정).
|
||||
#: ⚠ 「최소」라 실제 설계값이 더 클 수 있어 **미확정·금액 밖은 그대로**.
|
||||
NOTE_DEFAULT_WALL = (
|
||||
"⚠ 시설 지점에 {filled} 을 안 적어 기본값으로 섰음 — 미확정이라 금액에 안 들어감"
|
||||
)
|
||||
#: 꼬리 — 관 벽은 횡단도와 같은 높이.
|
||||
NOTE_WALL_HEIGHT_PIPE = " · 높이는 횡단도와 같은 관경 기준 최소 높이(실제 설계값은 더 클 수 있음)"
|
||||
|
||||
#: 독립 기슭막이 치수 **한 벌**(`resources/data_masonry/revetment_sabang_*.json`) — B05 칸 밑 근거와
|
||||
#: 이 사유가 같은 파일을 읽음(2026-09-14 브레인 판정). 높이는 **설계자 입력** — 등록부 기본 2.5 걷음.
|
||||
REVET_SABANG_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_masonry"
|
||||
REVET_SABANG_PREFIX = "revetment_sabang_"
|
||||
UNCONFIRMED_OWN_HEIGHT = "높이를 안 적음 — {rule}({source}) 설계자 입력 · 적으면 금액이 섬"
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def load_revetment_sabang() -> dict[str, Any]:
|
||||
"""독립 기슭막이 치수 한 벌. 없으면 빈 표 — 사유가 근거 없이 짧아질 뿐 값은 안 바뀜."""
|
||||
files = sorted(REVET_SABANG_DIR.glob(REVET_SABANG_PREFIX + "*.json"))
|
||||
return json.loads(files[-1].read_text(encoding="utf-8")) if files else {}
|
||||
|
||||
|
||||
def _span(values: list[float], unit: str = "") -> str:
|
||||
return f"{values[0]:g}~{values[1]:g}{unit}"
|
||||
|
||||
|
||||
def own_revetment_basis() -> str:
|
||||
"""독립 기슭막이 근거 한 줄 — 높이·계획비탈·둑마루·뒷채움, 쪽·줄 번호 그대로."""
|
||||
data = load_revetment_sabang()
|
||||
items = data.get("items") or {}
|
||||
if not items:
|
||||
return ""
|
||||
height, slope = items["height"], items["face_slope"]
|
||||
crown, pebble = items["crown_thickness"], items["backfill_pebble"]
|
||||
return (
|
||||
f"ⓘ 독립 기슭막이 치수 근거 — 높이 {height['rule']}({height['source']})"
|
||||
f" · 계획비탈 1:{_span(slope['standard_ratio'])}({slope['source']})"
|
||||
f" · 둑마루 두께 {_span(crown['standard_m'], 'm')}({crown['source']} · 콘크리트 기준)"
|
||||
f" · 뒷채움 조약돌 두께 {pebble['source']} · {data.get('scope', '')}"
|
||||
)
|
||||
|
||||
|
||||
def own_height_unconfirmed() -> str:
|
||||
"""높이를 안 적은 독립 기슭막이의 미확정 까닭."""
|
||||
height = (load_revetment_sabang().get("items") or {}).get("height") or {}
|
||||
return UNCONFIRMED_OWN_HEIGHT.format(
|
||||
rule=height.get("rule", "계획홍수위 + 0.5~0.7m"),
|
||||
source=height.get("source", "사방기술교본"),
|
||||
)
|
||||
|
||||
|
||||
#: 금액에서 뺄 까닭(2026-09-14 브레인 판정) — 줄·표·그림은 서되 내역 합에는 안 듦.
|
||||
UNCONFIRMED_WALL = "벽 치수({filled})가 시설 지점에 없어 기본값으로 섰음 — 적으면 금액이 섬"
|
||||
NOTE_SIDE_UNKNOWN = (
|
||||
"⚠ 설치 측 「{side}」인데 두 칸(유입·유출) 값이 달라 어느 칸이 그쪽인지 서버가 못 가림"
|
||||
"(횡단 지형이 정함) — 값을 안 세움 · 두 칸을 같게 적으면 섬"
|
||||
)
|
||||
|
||||
|
||||
def _blank(value: Any) -> bool:
|
||||
return value is None or value == ""
|
||||
|
||||
|
||||
def _num_or_zero(value: Any) -> float:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _revet_values(
|
||||
options: dict[str, Any],
|
||||
role: str,
|
||||
defaults: dict[str, Any],
|
||||
legacy: bool,
|
||||
min_height_m: float | None = None,
|
||||
) -> tuple[dict[str, Any], list[str]]:
|
||||
"""벽 한쪽 제원과 기본값으로 채운 칸 — 등록부 기본값 · 관 벽 높이는 관경 기준 최소 높이."""
|
||||
values: dict[str, Any] = {}
|
||||
filled: list[str] = []
|
||||
for name, label in REVET_FIELDS:
|
||||
value = options.get(f"{role}_revet_{name}")
|
||||
if _blank(value) and legacy:
|
||||
value = options.get(name)
|
||||
if _blank(value):
|
||||
value = defaults.get(name if legacy else f"{role}_revet_{name}")
|
||||
if value is None and name == "height_m" and min_height_m is not None:
|
||||
value = min_height_m
|
||||
label = f"{label}(관경 기준)"
|
||||
if label and value is not None:
|
||||
filled.append(f"{label} {value}")
|
||||
values[name] = value
|
||||
return values, filled
|
||||
|
||||
|
||||
def _pipe_diameter_m(options: dict[str, Any], defaults: dict[str, Any]) -> float:
|
||||
"""관경(m) — 저장값, 없으면 등록부 기본(B06 세트 스펙과 같은 채움)."""
|
||||
raw = options.get("pipe_diameter_mm")
|
||||
if _blank(raw):
|
||||
raw = defaults.get("pipe_diameter_mm")
|
||||
return _num_or_zero(raw) / 1000.0 or 1.0
|
||||
|
||||
|
||||
#: 다단 추가 기슭막이(④, 2026-09-14) — 횡단 설계가 정본이고 **실제로 선** 단 수·높이는 지형이 정함.
|
||||
#: 서버 Node 재계산(`B06_Section_Structure_Layouts.extraWallRows`)이 소유 측점 `design.extra_walls` 에
|
||||
#: 「선 다단 벽 목록」을 남기고 여기서 읽음(관 연장 `pipe_length_m` 과 같은 길).
|
||||
EXTRA_WALLS_KEY = "extra_walls"
|
||||
EXTRA_SIDE_LABELS = {"outlet": "유출측", "basin": "유입측"}
|
||||
#: 다단이 딸린 기준벽 — 유출측 다단은 유출 벽, 유입측(집수정 계류측) 다단은 유입 벽.
|
||||
EXTRA_SIDE_ROLES = {"outlet": "outlet", "basin": "inlet"}
|
||||
UNCONFIRMED_EXTRA_HEIGHT = (
|
||||
"다단 높이를 안 적음 — 기본 1.5m 를 지반에 맞춰 그린 {height}m 로 섰음 · 적으면 금액이 섬"
|
||||
)
|
||||
UNCONFIRMED_EXTRA_FORM = "다단 형태를 안 정해 기준벽의 기본 형태 「{form}」로 섰음"
|
||||
#: 첫 미확정 단 아래는 횡단도 면적도 없는 것으로 봄(B06 `confirmedOnly`) — 같은 선에서 끊음.
|
||||
UNCONFIRMED_EXTRA_ABOVE = "윗단({above}단)이 미확정이라 이 단도 미확정 — 윗단 높이를 적으면 섬"
|
||||
#: 벽 몸이 성토 폐회로에도 듦(2026-09-06 사용자 확정 「구조물 면적 안 뺌」) — 고치지 않고 사유로(2026-09-14 브레인).
|
||||
#: 넓이는 서버 Node 가 횡단도 벽 몸 ∩ 성토 폐회로로 잼(`wallBodyInFillM2` · `fill_overlap_m2`).
|
||||
NOTE_FILL_OVERLAP = (
|
||||
"⚠ 벽 몸 자리가 성토 면적에도 들어 있음(2026-09-06 확정 「구조물 면적 안 뺌」) — 이 측점 횡단도 "
|
||||
"벽 몸 {area:.2f}㎡ × 연장 {length:g}m ≈ {volume:.1f}㎥ 를 벽과 성토에 두 번 셈 · "
|
||||
"벽 뒤 막자갈 자리도 성토선 아래라 더 겹침"
|
||||
)
|
||||
|
||||
|
||||
#: 관 기준벽(유입·유출) 벽 몸 겹침 ㎡ — `design.wall_fill_overlap` {inlet, outlet}(서버 Node 가 잼, ㉡ 2026-09-14).
|
||||
#: 호출부(`Router_Material`)를 안 늘리려고 다단 목록 통에 `base` 표시 칸으로 실어 옴 — 다단 줄은 거름.
|
||||
WALL_OVERLAP_KEY = "wall_fill_overlap"
|
||||
|
||||
|
||||
def extra_walls_from_designs(designs: list[dict[str, Any]] | None) -> dict[float, list[dict]]:
|
||||
"""측점별 「선 다단 벽 목록」 — 목록이 있는 측점만(빈 목록도 담음 — 「다단 없음」).
|
||||
기준벽 겹침 값이 있으면 `{"base": 역할, "fill_overlap_m2": ㎡}` 칸을 뒤에 붙임."""
|
||||
found: dict[float, list[dict]] = {}
|
||||
for row in designs or []:
|
||||
design = row.get("design") if isinstance(row, dict) else None
|
||||
if not isinstance(design, dict):
|
||||
continue
|
||||
walls = design.get(EXTRA_WALLS_KEY)
|
||||
overlaps = design.get(WALL_OVERLAP_KEY)
|
||||
if not isinstance(walls, list) and not isinstance(overlaps, dict):
|
||||
continue
|
||||
entries = list(walls) if isinstance(walls, list) else []
|
||||
if isinstance(overlaps, dict):
|
||||
entries += [{"base": role, "fill_overlap_m2": area} for role, area in overlaps.items()]
|
||||
found[round(float(row.get("chainage_m") or 0.0), 3)] = entries
|
||||
return found
|
||||
|
||||
|
||||
def _extra_walls_at(extra_walls: dict[float, list[dict]] | None, chainage: float) -> list[dict]:
|
||||
"""관 자리의 주인 측점 목록 — 스냅 때문에 최대 0.5m 어긋남(`SECTION_MATCH_TOLERANCE_M`)."""
|
||||
if not extra_walls:
|
||||
return []
|
||||
best = min(extra_walls, key=lambda value: abs(value - chainage))
|
||||
return extra_walls[best] if abs(best - chainage) <= SECTION_MATCH_TOLERANCE_M else []
|
||||
|
||||
|
||||
def facility_structures(
|
||||
points: list[dict[str, Any]],
|
||||
extra_walls: dict[float, list[dict]] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""계곡 통과 시설(`pipe_points.json`) → 원단위 전개가 읽는 구조물 줄 (A1, 2026-09-14).
|
||||
|
||||
⚠ 관 자체는 안 냄 — `build_rows` 가 관 연장으로 셈.
|
||||
⚠ 다단 추가 기슭막이는 `extra_walls`(측점 → 선 벽 목록, `extra_walls_from_designs`)로 줄을 세움.
|
||||
⚠ 안 적힌 벽 칸은 **등록부 기본값** — 그 사실을 줄 사유로 붙이고 `unconfirmed` 로 금액 합에서 뺌
|
||||
(2026-09-14 브레인 판정 「줄은 서되 금액은 실제 값이 있을 때만」).
|
||||
⚠ 집수정·기슭막이 터파기·되메우기는 전개 성분(`destination: earthwork`)이라 토공집계로만 감.
|
||||
"""
|
||||
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
|
||||
from B06_Section.B06_Section_Engine_Culvert import pipe_wall_min_height_m
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureSummary import pipe_row_id
|
||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import attachments_of, child_row
|
||||
|
||||
types = structure_type_map()
|
||||
|
||||
def defaults(type_id: str) -> dict[str, Any]:
|
||||
found = types.get(type_id)
|
||||
return {option.key: option.default for option in found.options} if found else {}
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
for point in points or []:
|
||||
facility = str(point.get("facility") or FACILITY_PIPE)
|
||||
chainage = float(point.get("chainage_m") or 0.0)
|
||||
options = dict(point.get("options") or {})
|
||||
base = {
|
||||
"structure_id": pipe_row_id(point),
|
||||
"type_id": facility,
|
||||
"chainage_m": chainage,
|
||||
"start_m": point.get("start_m") if point.get("start_m") is not None else chainage,
|
||||
"end_m": point.get("end_m") if point.get("end_m") is not None else chainage,
|
||||
"options": options,
|
||||
}
|
||||
if facility not in (FACILITY_PIPE, "revetment"):
|
||||
rows.append(base) # BOX암거·물넘이·세월교 — 전개식·관측값이 없으면 사유로 섬
|
||||
continue
|
||||
legacy = facility == "revetment" # 독립 기슭막이 — 벽 칸 밖의 제원(뒷길이 등)도 벽이 씀
|
||||
wall_defaults = defaults(facility)
|
||||
roles = list(REVET_ROLES)
|
||||
inlet_notes: list[str] = []
|
||||
if not legacy:
|
||||
parent = {**base, "type_id": "pipe"}
|
||||
children = [r for r in attachments_of(parent) if r["type_id"] != "pipe_inlet_basin"]
|
||||
inlet = str(options.get("inlet_type") or wall_defaults.get("inlet_type") or "")
|
||||
if inlet == INLET_BASIN:
|
||||
# 형식을 안 골랐어도 줄은 세움 — 관측표가 「형식을 골라야 섬」 사유를 냄.
|
||||
children.append(child_row(parent, "pipe_inlet_basin", "유입부 집수정"))
|
||||
roles = roles[1:]
|
||||
elif options.get("inlet_basin_form"):
|
||||
inlet_notes.append(
|
||||
f"⚠ 집수정 형식({options['inlet_basin_form']})이 적혀 있으나 유입구 구조가 "
|
||||
f"「{inlet}」라 집수정은 안 셈 — 유입구를 「집수정」으로 바꾸면 섬"
|
||||
)
|
||||
rows.extend(children)
|
||||
walls = []
|
||||
min_height = (
|
||||
None if legacy else pipe_wall_min_height_m(_pipe_diameter_m(options, wall_defaults))
|
||||
)
|
||||
for role, pipe_label, own_label in roles:
|
||||
values, filled = _revet_values(options, role, wall_defaults, legacy, min_height)
|
||||
notes = list(inlet_notes) if role == "inlet" else []
|
||||
if filled:
|
||||
tail = "" if legacy else NOTE_WALL_HEIGHT_PIPE
|
||||
notes.append(NOTE_DEFAULT_WALL.format(filled=" · ".join(filled)) + tail)
|
||||
if legacy:
|
||||
# 독립 기슭막이 — 치수 한 벌 근거(사방 교본 기준 · 쪽·줄 번호)를 늘 붙임.
|
||||
basis = own_revetment_basis()
|
||||
if basis:
|
||||
notes.append(basis)
|
||||
walls.append([role, own_label if legacy else pipe_label, values, notes, False, filled])
|
||||
side = str(options.get("side") or "") if legacy else ""
|
||||
if side in ("좌", "우"):
|
||||
# 좌·우가 유입/유출 어느 칸인지는 **횡단 지형**이 정함 — 두 칸이 같을 때만 한 벽으로 셈.
|
||||
same = walls[0][2] == walls[1][2]
|
||||
walls = [[walls[0][0], f"{side} 벽", walls[0][2], walls[0][3], not same, walls[0][5]]]
|
||||
if not same:
|
||||
walls[0][3].append(NOTE_SIDE_UNKNOWN.format(side=side))
|
||||
foundation = options.get("foundation" if legacy else "revet_foundation")
|
||||
kept = {k: v for k, v in options.items() if not k.startswith(("inlet_", "outlet_"))}
|
||||
station_walls = _extra_walls_at(extra_walls, chainage)
|
||||
base_overlap = {
|
||||
str(entry["base"]): _num_or_zero(entry.get("fill_overlap_m2"))
|
||||
for entry in station_walls
|
||||
if entry.get("base")
|
||||
}
|
||||
for role, label, values, notes, withheld, filled in walls:
|
||||
before, after = _num_or_zero(values["before_m"]), _num_or_zero(values["after_m"])
|
||||
row = child_row(base, "revetment", label, f"{role}_revet")
|
||||
reasons = [UNCONFIRMED_WALL.format(filled=" · ".join(filled))] if filled else []
|
||||
if legacy and _blank(values["height_m"]):
|
||||
# 높이는 설계자 입력(계획홍수위 + 여유고) — 없으면 줄만 서고 금액 밖.
|
||||
reasons.append(own_height_unconfirmed())
|
||||
# 좌·우 한 벽으로 합친 독립 기슭막이는 그 측점에 선 벽 몸 전부.
|
||||
overlap = (
|
||||
sum(base_overlap.values())
|
||||
if legacy and len(walls) == 1
|
||||
else base_overlap.get(role, 0.0)
|
||||
)
|
||||
if overlap > 0 and not reasons: # 미확정 벽은 금액에 안 듦 — 두 번 셀 금액이 없음
|
||||
notes.append(
|
||||
NOTE_FILL_OVERLAP.format(
|
||||
area=overlap, length=before + after, volume=overlap * (before + after)
|
||||
)
|
||||
)
|
||||
row.update(
|
||||
start_m=chainage - before,
|
||||
end_m=chainage + after,
|
||||
options={**(kept if legacy else {}), **values, "foundation": foundation},
|
||||
notes=notes,
|
||||
withheld=withheld,
|
||||
unconfirmed=" · ".join(reasons),
|
||||
)
|
||||
rows.append(row)
|
||||
# 다단 — 기준벽의 기초·(독립이면) 제원 칸을 물려받고, 형태·높이·구간은 선 벽 목록 값.
|
||||
parents = {role: (values, filled) for role, _label, values, _notes, _w, filled in walls}
|
||||
counts = {"outlet": 0, "basin": 0}
|
||||
first_unset: dict[str, int] = {}
|
||||
for tier in station_walls:
|
||||
if tier.get("base"):
|
||||
continue
|
||||
side = "basin" if str(tier.get("side")) == "basin" else "outlet"
|
||||
counts[side] += 1
|
||||
if not tier.get("height_set"):
|
||||
first_unset.setdefault(side, counts[side])
|
||||
parent_values, parent_filled = parents.get(
|
||||
EXTRA_SIDE_ROLES[side], next(iter(parents.values()), ({}, []))
|
||||
)
|
||||
height = _num_or_zero(tier.get("height_m"))
|
||||
form = str(tier.get("form") or parent_values.get("form") or "")
|
||||
before, after = _num_or_zero(tier.get("before_m")), _num_or_zero(tier.get("after_m"))
|
||||
reasons = []
|
||||
if not tier.get("height_set"):
|
||||
reasons.append(UNCONFIRMED_EXTRA_HEIGHT.format(height=f"{height:g}"))
|
||||
elif first_unset.get(side, counts[side]) < counts[side]:
|
||||
reasons.append(UNCONFIRMED_EXTRA_ABOVE.format(above=first_unset[side]))
|
||||
if not tier.get("form_set") and any(str(f).startswith("형태") for f in parent_filled):
|
||||
reasons.append(UNCONFIRMED_EXTRA_FORM.format(form=form))
|
||||
label = f"{EXTRA_SIDE_LABELS[side]} 다단 기슭막이 {counts[side]}단"
|
||||
row = child_row(base, "revetment", label, str(tier.get("key") or label))
|
||||
tier_notes = [
|
||||
f"ⓘ 횡단도에 선 다단 벽 — 높이 {height:g}m · 형태 {form} · 전 {before:g}/후 {after:g}m"
|
||||
]
|
||||
overlap = _num_or_zero(tier.get("fill_overlap_m2"))
|
||||
if overlap > 0 and not reasons: # 미확정 단은 면적에도 금액에도 안 듦
|
||||
tier_notes.append(
|
||||
NOTE_FILL_OVERLAP.format(
|
||||
area=overlap, length=before + after, volume=overlap * (before + after)
|
||||
)
|
||||
)
|
||||
row.update(
|
||||
start_m=chainage - before,
|
||||
end_m=chainage + after,
|
||||
options={
|
||||
**(kept if legacy else {}),
|
||||
"form": form,
|
||||
"height_m": height,
|
||||
"length_m": before + after,
|
||||
"before_m": before,
|
||||
"after_m": after,
|
||||
"foundation": foundation,
|
||||
},
|
||||
notes=tier_notes,
|
||||
withheld=False,
|
||||
unconfirmed=" · ".join(reasons),
|
||||
)
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
「토공집계 지장목제거로 이미 섬」이라고 가리키기만** 한다.
|
||||
|
||||
⚠ 값이 없는 줄의 사유를 적는다
|
||||
· 표토제거(9-15) — 면적은 사면적에서 나오나 **두께·대상 구간이 설계로 안 정해져 있다**.
|
||||
· 표토제거(9-15-2) — **노면 + 절토 사면 ㎡** · 노면 폭을 못 세면 안 섬(2026-09-13 「실무대로」).
|
||||
· 제근·뿌리다듬기(9-20~21) — 단위가 **「개」(그루 수)**인데 입목 본수를 우리가 안 든다.
|
||||
· 규준틀(11-2) — **개소** 산정 기준(구조물·절성토 구간별 몇 개소)이 안 정해져 있다.
|
||||
|
||||
@@ -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"] # 갈라 나간 뒤에도 여기서 읽을 수 있게
|
||||
|
||||
@@ -110,13 +111,15 @@ def preparation_rows(
|
||||
topsoil_thickness_m: float | None = None,
|
||||
topsoil_haul_distance_m: float | None = None,
|
||||
stand_volume_class: str | None = None,
|
||||
road_surface_area_m2: float | None = None,
|
||||
topsoil_road_only: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""준비공 줄 — 값이 서는 것과 안 서는 것을 **한 목록에** 낸다."""
|
||||
slope = slope_totals or {}
|
||||
tree_area = float(slope.get("tree_removal_fill", 0.0)) + float(
|
||||
slope.get("tree_removal_cut", 0.0)
|
||||
)
|
||||
topsoil = _topsoil_row(slope, topsoil_thickness_m)
|
||||
topsoil = _topsoil_row(slope, road_surface_area_m2, topsoil_road_only)
|
||||
return [
|
||||
{
|
||||
"group": "준비공",
|
||||
@@ -141,7 +144,7 @@ def preparation_rows(
|
||||
},
|
||||
topsoil,
|
||||
# ⚠ 법이 요구하는 **운반·적치** — 제거 물량이 곧 밑수다(별표2).
|
||||
_topsoil_haul_row(topsoil, topsoil_haul_distance_m),
|
||||
_topsoil_haul_row(topsoil, topsoil_thickness_m, topsoil_haul_distance_m),
|
||||
_root_removal_row(slope, stand_volume_class),
|
||||
*_root_steps_rows(slope, stand_volume_class),
|
||||
_batter_frame_row(list(slope_rows)),
|
||||
@@ -149,53 +152,59 @@ def preparation_rows(
|
||||
]
|
||||
|
||||
|
||||
def _topsoil_row(slope: dict[str, float], thickness_m: float | None) -> dict[str, Any]:
|
||||
"""표토제거 — **두께는 품셈이 아니라 설계가 정한다**(9-15 [주]② 「T : 표토두께(m)」).
|
||||
def _topsoil_row(
|
||||
slope: dict[str, float], road_area_m2: float | None, road_only: bool = False
|
||||
) -> dict[str, Any]:
|
||||
"""표토제거 — **밑수 ㎡ · 대상 · 20m 압토 셋이 한 묶음**(2026-09-13 판정 · PLAN 10장).
|
||||
|
||||
⚠ 두께를 안 넣으면 **0 으로 때우지 않고** 물량을 안 낸다. 대상 면적은 절·성토 사면적을
|
||||
쓴다(사면 계열의 면고르기 면적과 같은 자리).
|
||||
① 밑수 **㎡** — 품셈 9-15-2 가 T=0.2 m 를 **품 조건**으로 둔 면적 축(실무 영월 「표토제거
|
||||
답외구간 M2」). 두께는 여기 안 곱한다 — 운반 부피에만 쓴다.
|
||||
② 대상 **노면 + 절토대상지** — 별표2 Ⅰ.2.차.(6) 문언. 성토대상지는 (7) 「제거·정리」라 안 셈.
|
||||
`road_only` 면 노면만(영월 한 건 관측 · 설계자 선택 · 2026-09-14 판정).
|
||||
③ **20 m 압토는 품에 듦** — 운반 줄은 그 넘는 몫만(`_topsoil_haul_row`).
|
||||
⚠ 하나만 고치면 합계가 그럴듯해 보이면서 틀린다 — 셋을 함께 바꿨다.
|
||||
"""
|
||||
area = float(slope.get("face_dressing_fill", 0.0)) + float(slope.get("face_dressing_cut", 0.0))
|
||||
# ⚠ 면적이 0 이면 **0 ㎥ 를 「값 있음」으로 내지 않는다** — 0 은 「없음」과 구별이 안 되고,
|
||||
# 받는 쪽이 「표토가 없는 노선」으로 읽는다. 절·성토 사면적이 0 인 임도는 성립하지
|
||||
# 않으므로 이 자리는 사실상 **사면표가 아직 안 선 것**이다(2026-09-09 감사에서 잡음).
|
||||
cut = 0.0 if road_only else float(slope.get("face_dressing_cut", 0.0))
|
||||
base = {"group": "준비공", "item": "표토제거", "unit": "㎡", "work_item_code": TOPSOIL_CODE}
|
||||
if road_area_m2 is None:
|
||||
return {
|
||||
**base,
|
||||
"amount": None,
|
||||
"status": STATUS_PENDING,
|
||||
"reason": (
|
||||
"노면 면적을 못 셉니다 — 횡단 설계에 노체 끝(노면 폭)이 없는 측점이 있음. "
|
||||
f"대상은 노면 + 절토대상지(별표2) · 절토 사면 {cut:,.1f}㎡ 만으로는 세우지 않음"
|
||||
),
|
||||
"reference_amount": cut,
|
||||
}
|
||||
area = float(road_area_m2) + cut
|
||||
# ⚠ 면적이 0 이면 **0 ㎡ 를 「값 있음」으로 내지 않는다** — 받는 쪽이 「표토가 없는 노선」으로
|
||||
# 읽는다(2026-09-09 감사에서 잡음). 노면이 0 인 임도는 성립하지 않으므로 표가 안 선 것이다.
|
||||
if area <= 0:
|
||||
return {
|
||||
"group": "준비공",
|
||||
"item": "표토제거",
|
||||
"unit": "㎥",
|
||||
**base,
|
||||
"amount": None,
|
||||
"status": STATUS_PENDING,
|
||||
"reason": (
|
||||
"대상 면적이 0 ㎡ 입니다 — 사면표(면고르기 면적)가 아직 서지 않았습니다. "
|
||||
"0 ㎥ 로 내면 「표토가 없는 노선」으로 읽히므로 값을 세우지 않습니다"
|
||||
"대상 면적이 0 ㎡ 입니다 — 횡단·사면표가 아직 서지 않았습니다. "
|
||||
"0 ㎡ 로 내면 「표토가 없는 노선」으로 읽히므로 값을 세우지 않습니다"
|
||||
),
|
||||
"work_item_code": "FP-09-15",
|
||||
}
|
||||
if thickness_m is None or float(thickness_m) <= 0:
|
||||
if road_only:
|
||||
return {
|
||||
"group": "준비공",
|
||||
"item": "표토제거",
|
||||
"unit": "㎥",
|
||||
"amount": None,
|
||||
"status": STATUS_PENDING,
|
||||
"reason": (
|
||||
"표토 두께가 아직 입력되지 않았습니다 — 두께는 설계가 정하는 값입니다. "
|
||||
f"{TOPSOIL_ORIGINAL_APPLIED} "
|
||||
f"산출 조건에서 두께를 넣으면 값이 섭니다 (대상 면적 {area:,.1f}㎡)"
|
||||
),
|
||||
"reference_amount": area,
|
||||
"work_item_code": "FP-09-15",
|
||||
**base,
|
||||
"amount": area,
|
||||
"status": STATUS_READY,
|
||||
"reason": f"노면 {area:,.1f}㎡ (노면만 — 산출 조건에서 고름)"
|
||||
f" — {TOPSOIL_ROAD_ONLY_BASIS}",
|
||||
}
|
||||
thickness = float(thickness_m)
|
||||
return {
|
||||
"group": "준비공",
|
||||
"item": "표토제거",
|
||||
"unit": "㎥",
|
||||
"amount": area * thickness,
|
||||
**base,
|
||||
"amount": area,
|
||||
"status": STATUS_READY,
|
||||
"reason": (f"사면적 {area:,.1f}㎡ × 두께 {thickness:g}m (품셈 9-15) · {TOPSOIL_AREA_GAP}"),
|
||||
"work_item_code": "FP-09-15",
|
||||
"reason": (
|
||||
f"노면 {float(road_area_m2):,.1f}㎡ + 절토 사면 {cut:,.1f}㎡ — {TOPSOIL_TARGET_BASIS}"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -213,19 +222,22 @@ TOPSOIL_ORIGINAL_APPLIED = (
|
||||
" — 값을 자동으로 넣지 않되, 원문에 있는 값이므로 참고할 것"
|
||||
)
|
||||
|
||||
#: ⚠⚠ **대상 면적이 법 문언과 어긋난다**(2026-09-09 준비공 축 감사에서 잡음).
|
||||
#: 별표2 Ⅰ.2.차.(6) 은 대상을 **「노면·절토대상지」**로 못박고, 성토대상지는 (7) 에서
|
||||
#: 「표토 등은 **제거·정리**한다」로 따로 두어 **운반·적치 의무를 안 건다.**
|
||||
#: 우리는 `face_dressing_cut + face_dressing_fill` 로 **절토·성토 사면적을 다 더하고**
|
||||
#: **노면(연장 × 노폭)은 안 센다** — 두 방향으로 어긋난 셈이다.
|
||||
#: ㉡ 계열(「또는」을 다 더함) — 성토 사면까지 운반·적치 물량에 실림
|
||||
#: 빠짐 — 노면 표토가 아예 안 섬(법이 첫째로 든 대상)
|
||||
#: ⚠ **값을 임의로 고치지 않는다** — 실무가 사면적 한 덩이로 잡는지, 노면을 따로 잡는지가
|
||||
#: 사용자·실무자 확인 사항이다. 지금은 **사유로 드러내기만** 한다.
|
||||
TOPSOIL_AREA_GAP = (
|
||||
"⚠ 대상 면적 확인 필요 — 별표2 는 「노면·절토대상지」로 두는데 우리는 절토·성토"
|
||||
" **사면적을 다 더하고 노면은 안 셈**. 성토대상지는 별표2 (7) 이 「제거·정리」로만 두어"
|
||||
" 운반·적치 의무를 안 검. 실무 확인 뒤 대상을 확정할 것"
|
||||
#: 표토제거 공종 — **답외구간**(9-15-2). 임도는 논(畓)이 아니고 실무 영월도 「답외구간」으로 셈.
|
||||
TOPSOIL_CODE = "FP-09-15-02"
|
||||
#: ⭐ 2026-09-13 판정 「실무대로」 — 대상·밑수·압토 셋의 근거(화면 사유에 그대로 보임).
|
||||
#: 종전(2026-09-09 감사)엔 절토·성토 **사면적을 다 더하고 노면은 안 세** 법 문언과 두 방향으로
|
||||
#: 어긋나 있었고 사유로만 드러내고 있었다.
|
||||
TOPSOIL_TARGET_BASIS = (
|
||||
"대상은 별표2 Ⅰ.2.차.(6) 「노면·절토대상지」 · 성토대상지는 (7) 「제거·정리」라 안 셈"
|
||||
" · 밑수 ㎡(품셈 9-15-2 가 T=0.2m 를 품 조건으로 둔 면적 축"
|
||||
" · 실무 영월 「표토제거 답외구간 M2」)"
|
||||
" · 20m 압토는 제거 품에 듦(2026-09-13 판정 「실무대로」)"
|
||||
)
|
||||
#: 노면만 — 설계자가 고른 경우의 근거. ⚠ 법 문언이 아니라는 사실을 사유에 함께 적는다.
|
||||
TOPSOIL_ROAD_ONLY_BASIS = (
|
||||
"⚠ 법 문언(별표2 Ⅰ.2.차.(6))은 「노면·절토대상지」라 기본은 노면 + 절토"
|
||||
" · 노면만은 실무 영월 한 건(1m당 3.34㎡) 관측이라 설계자 선택"
|
||||
" · 밑수 ㎡ · 20m 압토는 제거 품에 듦"
|
||||
)
|
||||
|
||||
#: 표토 운반·적치 — ⚠ **법이 요구하는데 우리가 제거만 세고 있던 자리**(2026-09-09).
|
||||
@@ -237,10 +249,11 @@ TOPSOIL_AREA_GAP = (
|
||||
TOPSOIL_HAUL_LAW = (
|
||||
"⚠ 법정 의무 — 시행규칙 별표2 「표토는 전량 제거한 후 … 최고 홍수위보다 높은 장소로"
|
||||
" 운반하고 쌓아두어야 한다」. 제거만 세면 운반이 빠짐"
|
||||
" · ⚠⚠ **다만 제거 품(9-15-2)에 이미 L=20m 압토가 들어 있음** — 실무 내역서도"
|
||||
" 「표토제거 답외구간 / M2」 한 줄뿐이고 운반 줄이 따로 없음. 이 줄은 **20m 를 넘는"
|
||||
" 몫**일 때만 새 줄이며, 그 가름을 아직 안 함(확인 필요)"
|
||||
" · 제거 품(9-15-2)에 L=20m 압토가 들어 있어 이 줄은 **20m 를 넘는 몫**만 셈"
|
||||
" (실무 영월 「표토제거 답외구간 / M2」 한 줄 · 운반 줄 없음)"
|
||||
)
|
||||
#: 제거 품이 품은 압토 거리(m) — 품셈 9-15-2 원문 적용값 `L(운반거리) 20m`.
|
||||
TOPSOIL_PUSH_INCLUDED_M = 20.0
|
||||
|
||||
|
||||
#: 제근 — ⚠ **본수가 아니라 임목축적으로 갈린다**(2026-09-09 원문 확인).
|
||||
@@ -401,26 +414,56 @@ def _root_steps_rows(
|
||||
]
|
||||
|
||||
|
||||
def _topsoil_haul_row(topsoil: dict[str, Any], distance_m: float | None) -> dict[str, Any]:
|
||||
"""표토 운반 — 물량은 제거 물량 그대로, 거리는 설계 입력.
|
||||
def _topsoil_haul_row(
|
||||
topsoil: dict[str, Any], thickness_m: float | None, distance_m: float | None
|
||||
) -> dict[str, Any]:
|
||||
"""표토 운반 — 부피는 **제거 면적 × 두께**, 거리는 설계 입력에서 **20m 압토를 뺀 몫**.
|
||||
|
||||
⚠ 제거 줄이 안 서면(두께 미입력) 운반도 안 선다 — 밑수가 그 줄이기 때문이다.
|
||||
⚠ 제거 줄이 안 서거나 두께가 비면 운반도 안 선다 — 0 으로 때우지 않는다.
|
||||
"""
|
||||
amount = topsoil.get("amount")
|
||||
area = topsoil.get("amount")
|
||||
distance = None
|
||||
if distance_m is not None:
|
||||
try:
|
||||
distance = float(distance_m)
|
||||
except (TypeError, ValueError):
|
||||
distance = None
|
||||
if amount is None:
|
||||
if area is None:
|
||||
return {
|
||||
"group": "준비공",
|
||||
"item": "표토 운반·적치",
|
||||
"unit": "㎥",
|
||||
"amount": None,
|
||||
"status": STATUS_PENDING,
|
||||
"reason": f"{TOPSOIL_HAUL_LAW} · 제거 물량이 아직 안 서서 운반도 못 셈(두께 먼저)",
|
||||
"reason": f"{TOPSOIL_HAUL_LAW} · 제거 면적이 아직 안 서서 운반도 못 셈",
|
||||
"work_item_code": None,
|
||||
}
|
||||
if thickness_m is None or float(thickness_m) <= 0:
|
||||
return {
|
||||
"group": "준비공",
|
||||
"item": "표토 운반·적치",
|
||||
"unit": "㎥",
|
||||
"amount": None,
|
||||
"status": STATUS_PENDING,
|
||||
"reason": (
|
||||
f"{TOPSOIL_HAUL_LAW} · 운반 부피 = 제거 면적 × 표토 두께 — 두께가 아직 입력되지"
|
||||
f" 않았습니다. {TOPSOIL_ORIGINAL_APPLIED} (제거 면적 {float(area):,.1f}㎡)"
|
||||
),
|
||||
"reference_amount": float(area),
|
||||
"work_item_code": None,
|
||||
}
|
||||
amount = float(area) * float(thickness_m)
|
||||
if distance is not None and 0 < distance <= TOPSOIL_PUSH_INCLUDED_M:
|
||||
return {
|
||||
"group": "준비공",
|
||||
"item": "표토 운반·적치",
|
||||
"unit": "㎥",
|
||||
"amount": None,
|
||||
"status": STATUS_COUNTED_ELSEWHERE,
|
||||
"reason": (
|
||||
f"{TOPSOIL_HAUL_LAW} · 운반거리 {distance:g}m 는 제거 품의 20m 압토 안 — 따로 안 셈"
|
||||
),
|
||||
"reference_amount": amount,
|
||||
"work_item_code": None,
|
||||
}
|
||||
if distance is None or distance <= 0:
|
||||
@@ -438,19 +481,22 @@ def _topsoil_haul_row(topsoil: dict[str, Any], distance_m: float | None) -> dict
|
||||
"reference_amount": float(amount),
|
||||
"work_item_code": None,
|
||||
}
|
||||
beyond = distance - TOPSOIL_PUSH_INCLUDED_M
|
||||
return {
|
||||
"group": "준비공",
|
||||
"item": "표토 운반·적치",
|
||||
"unit": "㎥",
|
||||
"amount": float(amount),
|
||||
"amount": amount,
|
||||
"status": STATUS_READY,
|
||||
"reason": (
|
||||
f"{TOPSOIL_HAUL_LAW} · 제거 물량 그대로 · 운반거리 {distance:g}m(설계 입력)"
|
||||
f"{TOPSOIL_HAUL_LAW} · 제거 면적 {float(area):,.1f}㎡ × 두께 {float(thickness_m):g}m"
|
||||
f" · 운반거리 {distance:g}m(설계 입력) − 압토 {TOPSOIL_PUSH_INCLUDED_M:g}m"
|
||||
f" = {beyond:g}m"
|
||||
" · 갈래 「토사」로 보냄(표토는 토사임 — 덤프 운반 10-12 는 토사/암절취/발파암으로"
|
||||
" 갈리고 부모 코드에는 품이 없음)"
|
||||
),
|
||||
"work_item_code": "FP-10-12",
|
||||
"haul_distance_m": distance,
|
||||
"haul_distance_m": beyond,
|
||||
# ⚠ `FP-10-12` 는 **부모**다 — 품이 붙은 것은 잎(10-12-1 토사 · -2 암절취 · -3 발파암)
|
||||
# 뿐이라 갈래를 안 보내면 B09 가 「후보 3건」만 보이고 금액이 안 선다. 표토가 토사인
|
||||
# 것은 다툼이 없으므로 여기서 갈래를 실어 보낸다(2026-09-09 마스터 대조에서 잡음).
|
||||
@@ -477,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(
|
||||
@@ -503,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",
|
||||
@@ -620,6 +671,12 @@ def build_table(
|
||||
stand_volume_class: str | None = None,
|
||||
chipping_enabled: Any = False,
|
||||
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 = (
|
||||
@@ -629,9 +686,19 @@ def build_table(
|
||||
topsoil_thickness_m,
|
||||
topsoil_haul_distance_m,
|
||||
stand_volume_class,
|
||||
road_surface_area_m2,
|
||||
topsoil_road_only,
|
||||
)
|
||||
+ erosion_rows(structures, names)
|
||||
+ chipping_rows(chipping_enabled, chipping_volume_m3)
|
||||
# 임목폐기물 — 톤 · 비목은 경비(내역 줄 아님 · 2026-09-14 판정).
|
||||
+ tree_waste_rows(
|
||||
slope_totals,
|
||||
tree_waste,
|
||||
tree_waste_unit_price_krw_per_ton,
|
||||
waste_separate_order,
|
||||
tree_waste_root_method,
|
||||
)
|
||||
+ ancillary_rows(ancillary_counts)
|
||||
)
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
"""준비공 — 임목폐기물 처리 한 줄 (톤) · 2026-09-14 판정 「폐기물처리비」.
|
||||
|
||||
⭐ 수량 산식 — 한국건설기술연구원 「건설현장 임목폐기물의 재활용 및 자원화 기술개발」(2012) 3.2
|
||||
= 환경친화적인 도로건설 지침 부록1 문단 493~508 「3) 임목폐기물」(산출근거: 조경설계요람)
|
||||
지상부 WA = k × 3.14 × (B/2)² × h × (1+p) × W1 × N (kg · 지침 식이 π 를 3.14 로 적음)
|
||||
뿌리부 — 지침이 두 방법을 둔다. 고르는 칸.
|
||||
① 분배비(기본) WR = WA × 15/85 (KICT 일반 수목류 — 2026-09-14 판정 유지)
|
||||
② 뿌리분 체적 WR = 0.3927 × D³ × 1,300 × N (지침 「V = πD²(D/2 + RH2/3) = 0.3927D³」
|
||||
· 「UW1 뿌리부분의 단위중량 1,300kg/m3」). ⚠ D 는 지침이 「뿌리분의 직경(DBH, m)」으로
|
||||
적었으나 2026-09-14 판정으로 **뿌리분 직경**(흉고직경 아님) — 따로 받고 기본값 없음.
|
||||
k 수간 형상계수 0.5 · p 지엽 보합률(임목 0.3) — **산식 상수**라 칸이 없다.
|
||||
B 평균 흉고직경 · h 평균 수고 · W1 수목 단위체적중량 · N 본수 —
|
||||
**현장 조사값**이라 칸으로 받는다.
|
||||
N = 표본지 1,000㎡ 실측 본수 × 대상 면적 ÷ 1,000 (실정보고 예 「134/1000 × 7,450」 모양).
|
||||
대상 면적 = 벌목·지장목제거 면적(같은 나무를 벤 자리).
|
||||
⚠ 실무 실정보고(임목폐기물처리, 한 현장)는 뿌리 14:86 · 무게 0.65톤/㎥ ·
|
||||
처리비를 공급가액 뒤(승률 밖)에 두었다 — 판정은 법 문언·공인 자료 쪽(PLAN 10장).
|
||||
발주처가 요구하면 칸으로 열 자리.
|
||||
|
||||
⚠ 금액은 **처리단가가 없어** 못 선다(KCRA 중간처리단가 미확보) — 수동 단가를 넣으면
|
||||
금액이 서되 「미확정」으로 센다(빨간 테두리). 비목은 **경비**라 내역 줄이 아니고
|
||||
원가계산서 폐기물처리비 줄로 간다(예정가격작성기준 제19조③18호).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from B08_Quantity.B08_Quantity_Engine_Preparation_Status import STATUS_PENDING, STATUS_READY
|
||||
|
||||
TREE_WASTE_ITEM = "임목폐기물 처리"
|
||||
STEM_FORM_FACTOR = 0.5 # k
|
||||
FOLIAGE_ALLOWANCE = 0.3 # p — 임목(고립목은 1.0)
|
||||
PI_AS_WRITTEN = 3.14 # 지침 부록1 식 원문 · 실정보고도 3.14
|
||||
ROOT_SHARE, ABOVE_SHARE = 15.0, 85.0 # 일반 수목류 분배비
|
||||
ROOT_BALL_FACTOR = 0.3927 # V = 0.3927 D³ (지침 부록1)
|
||||
ROOT_BALL_UNIT_WEIGHT = 1300.0 # ㎏/㎥ (지침 부록1 UW1)
|
||||
ROOT_METHOD_RATIO, ROOT_METHOD_BALL = "ratio", "root_ball"
|
||||
|
||||
#: 칸 이름 · 화면 이름 · 단위 — 모두 채워야 수량이 선다.
|
||||
TREE_WASTE_INPUTS: tuple[tuple[str, str], ...] = (
|
||||
("stems_per_1000m2", "표본지 1,000㎡당 본수"),
|
||||
("dbh_cm", "평균 흉고직경(㎝)"),
|
||||
("height_m", "평균 수고(m)"),
|
||||
("unit_weight_kg_m3", "단위체적중량(㎏/㎥)"),
|
||||
)
|
||||
TREE_WASTE_BASIS = (
|
||||
"환경친화적인 도로건설 지침 부록1 · 한국건설기술연구원(2012) 3.2 — k 0.5 · p 0.3(임목) · π 3.14"
|
||||
" · N = 표본지 1,000㎡ 본수 × 벌목·지장목제거 면적"
|
||||
)
|
||||
ROOT_RATIO_BASIS = "뿌리 WR = WA × 15/85(분배비 · 일반 수목류)"
|
||||
ROOT_BALL_BASIS = "뿌리 WR = 0.3927 × D³ × 1,300㎏/㎥ × N(뿌리분 체적 · 지침 부록1)"
|
||||
ROOT_BALL_LABEL = "뿌리분 직경 D(m, 흉고직경 아님 · 굴취 흙덩이 직경)"
|
||||
#: 배출자 신고 경계 — 화면이 알릴 뿐 값을 바꾸지 않는다.
|
||||
REPORT_THRESHOLD_TON = 5.0
|
||||
NOT_SEPARATE_NOTE = (
|
||||
"임목폐기물은 분리발주 대상 아님(환경부 질의회신 — 임목폐기물처리 실정보고 p4 ·"
|
||||
" 건설폐기물 업무처리지침: 건설폐기물 아닌 사업장 일반폐기물)"
|
||||
)
|
||||
EXPENSE_NOTE = (
|
||||
"비목은 경비(예정가격작성기준 제19조③18호) — 내역 줄이 아니라 원가계산서 폐기물처리비로 감"
|
||||
)
|
||||
|
||||
|
||||
def _positive(value: Any) -> float | None:
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return number if number > 0 else None
|
||||
|
||||
|
||||
def _missing(inputs: dict[str, Any], root_method: str) -> list[str]:
|
||||
"""비어 있는 칸 이름 — 뿌리분 체적법이면 뿌리분 직경도 든다."""
|
||||
missing = [label for key, label in TREE_WASTE_INPUTS if _positive(inputs.get(key)) is None]
|
||||
if root_method == ROOT_METHOD_BALL and _positive(inputs.get("root_ball_diameter_m")) is None:
|
||||
missing.append(ROOT_BALL_LABEL)
|
||||
return missing
|
||||
|
||||
|
||||
def tree_waste_tons(
|
||||
area_m2: float, inputs: dict[str, Any], root_method: str = ROOT_METHOD_RATIO
|
||||
) -> tuple[float, float] | None:
|
||||
"""(지상부 톤, 뿌리부 톤). 칸이 하나라도 비거나 면적이 0 이면 `None`."""
|
||||
if area_m2 <= 0 or _missing(inputs, root_method):
|
||||
return None
|
||||
stems = float(inputs["stems_per_1000m2"]) * area_m2 / 1000.0
|
||||
diameter = float(inputs["dbh_cm"]) / 100.0
|
||||
above_kg = (
|
||||
STEM_FORM_FACTOR
|
||||
* PI_AS_WRITTEN
|
||||
* (diameter / 2.0) ** 2
|
||||
* float(inputs["height_m"])
|
||||
* (1.0 + FOLIAGE_ALLOWANCE)
|
||||
* float(inputs["unit_weight_kg_m3"])
|
||||
* stems
|
||||
)
|
||||
if root_method == ROOT_METHOD_BALL:
|
||||
ball = float(inputs["root_ball_diameter_m"])
|
||||
root_kg = ROOT_BALL_FACTOR * ball**3 * ROOT_BALL_UNIT_WEIGHT * stems
|
||||
else:
|
||||
root_kg = above_kg * ROOT_SHARE / ABOVE_SHARE
|
||||
return above_kg / 1000.0, root_kg / 1000.0
|
||||
|
||||
|
||||
def tree_waste_rows(
|
||||
slope_totals: dict[str, float] | None,
|
||||
inputs: dict[str, Any] | None,
|
||||
unit_price_krw_per_ton: Any = None,
|
||||
separate_order: Any = False,
|
||||
root_method: Any = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""임목폐기물 처리 한 줄 — 값이 없어도 줄은 선다(무엇을 넣으면 풀리는지 적음)."""
|
||||
slope = slope_totals or {}
|
||||
area = float(slope.get("tree_removal_fill", 0.0)) + float(slope.get("tree_removal_cut", 0.0))
|
||||
inputs = inputs or {}
|
||||
method = ROOT_METHOD_BALL if root_method == ROOT_METHOD_BALL else ROOT_METHOD_RATIO
|
||||
root_basis = ROOT_BALL_BASIS if method == ROOT_METHOD_BALL else ROOT_RATIO_BASIS
|
||||
base = {
|
||||
"group": "준비공",
|
||||
"item": TREE_WASTE_ITEM,
|
||||
"unit": "톤",
|
||||
"work_item_code": None,
|
||||
# 경비 — 내역에 세우지 않는다(인계가 이 칸을 본다).
|
||||
"bill": False,
|
||||
"bill_reason": EXPENSE_NOTE,
|
||||
"separate_order": bool(separate_order),
|
||||
}
|
||||
tons = tree_waste_tons(area, inputs, method)
|
||||
if tons is None:
|
||||
missing = _missing(inputs, method)
|
||||
why = (
|
||||
f"현장 조사값이 비어 있음 — {' · '.join(missing)}"
|
||||
if missing
|
||||
else "벌목·지장목제거 면적이 0 ㎡ — 사면표가 아직 안 섬"
|
||||
)
|
||||
return [
|
||||
{
|
||||
**base,
|
||||
"amount": None,
|
||||
"status": STATUS_PENDING,
|
||||
"reason": f"{why}. 산식: {TREE_WASTE_BASIS} · {root_basis}",
|
||||
"reference_amount": area,
|
||||
}
|
||||
]
|
||||
above, root = tons
|
||||
total = above + root
|
||||
notes = [
|
||||
f"지상부 WA {above:,.2f}톤 + 뿌리부 WR {root:,.2f}톤",
|
||||
TREE_WASTE_BASIS,
|
||||
root_basis,
|
||||
EXPENSE_NOTE,
|
||||
NOT_SEPARATE_NOTE,
|
||||
]
|
||||
if total >= REPORT_THRESHOLD_TON:
|
||||
notes.append("5톤 이상 — 배출자 신고 대상 사업장(폐기물관리법 시행령 제2조8호)")
|
||||
price = _positive(unit_price_krw_per_ton)
|
||||
row = {
|
||||
**base,
|
||||
"amount": total,
|
||||
"status": STATUS_READY,
|
||||
"reference_amount": area,
|
||||
"above_ground_ton": above,
|
||||
"root_ton": root,
|
||||
"unit_price_krw_per_ton": price,
|
||||
"amount_krw": math.floor(total * price) if price else None,
|
||||
# 수동 단가로 선 금액 — 화면 「미확정 N건」·빨간 테두리.
|
||||
"unconfirmed": 1 if price else 0,
|
||||
}
|
||||
if price:
|
||||
notes.append(
|
||||
f"처리비 {total:,.2f}톤 × {price:,.0f}원 = {math.floor(total * price):,}원"
|
||||
" (⚠ 수동 단가 — 미확정 · KCRA 중간처리단가 미확보)"
|
||||
)
|
||||
else:
|
||||
notes.append(
|
||||
"처리단가 없음(KCRA 중간처리단가 미확보) — 산출 조건에 수동으로 넣으면 금액이 섬"
|
||||
)
|
||||
row["reason"] = " · ".join(notes)
|
||||
return [row]
|
||||
@@ -0,0 +1,110 @@
|
||||
"""암 운반량을 **구성비로 가르기** — ㉱ (가) (2026-09-14 브레인 판정 · 8-1 사용자 확정).
|
||||
|
||||
B06 의 암은 **한 종류 자리표시**다 — 토사 토글을 끄면 저장값이 `ripping_rock` 이 되지만 그 뜻은
|
||||
「암」이고 「갈라 넣는 것은 설계내역 몫」(`B06_Section_UI_Cross_Design.ts` 머리 주석). 그런데 운반표·사토가
|
||||
그 이름(리핑암)을 **값처럼** 넘겨, 깎기 암은 구성비가 비어 막히는데 운반 암은 금액이 섰다.
|
||||
|
||||
⇒ 운반표를 만든 **한 곳**(`Router_Earthwork`)에서 암 줄을 흙깎기와 **같은 구성비**(`_split_by_rock`)와
|
||||
갈래별 시공법으로 가른다. 운반거리 탭·토공집계 운반 줄·인계·사토가 모두 이 표를 읽어 한 값이 된다.
|
||||
⚠ 유토곡선 거리·다짐 부피는 그대로(자리표시 C 로 쌓은 값) — 부피를 몫대로 나눌 뿐이라 검산이 안 흔들림.
|
||||
C 까지 구성비로 맞추는 것은 (나) 차례.
|
||||
⚠ 구성비가 비면 「암」 한 줄로 **막는다** — 깎기와 같은 사유(지어낸 갈래로 금액을 세우지 않음).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Iterable
|
||||
|
||||
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import SummaryInput, _split_by_rock
|
||||
from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import (
|
||||
BLOCKED_INPUT_MISSING,
|
||||
METHOD_TO_GROUND,
|
||||
NOTE_METHOD_MISSING,
|
||||
NOTE_ROCK_RATIO_MISSING,
|
||||
)
|
||||
|
||||
#: 유토곡선이 넘기는 암 이름 — 둘 다 자리표시로 본다(옛 자료 `blasting_rock` 도 「암」).
|
||||
ROCK_GROUNDS = ("리핑암", "발파암")
|
||||
#: 몫대로 나누는 수량 칸 — 운반표 줄(`HaulSummary.build_table`).
|
||||
SHARED_KEYS = ("volume_m3", "natural_m3", "work_m3m")
|
||||
|
||||
|
||||
def rock_shares(
|
||||
classes: Iterable[str], ratios_pct: dict[str, Any], methods: dict[str, str | None]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""갈래별 몫 — `{rock_class, fraction, ground, blocked_reason, note}`. 흙깎기와 같은 안분."""
|
||||
source = SummaryInput(rock_classes=list(classes), rock_ratios_pct=dict(ratios_pct or {}))
|
||||
shares = []
|
||||
for name, fraction, note in _split_by_rock(1.0, source):
|
||||
ground = "암" if name == "암" else METHOD_TO_GROUND.get(methods.get(name) or "")
|
||||
if name == "암":
|
||||
reason = NOTE_ROCK_RATIO_MISSING
|
||||
else:
|
||||
reason = "" if ground else NOTE_METHOD_MISSING
|
||||
shares.append(
|
||||
{
|
||||
"rock_class": name,
|
||||
"fraction": fraction,
|
||||
"ground": ground or name,
|
||||
"blocked_reason": reason,
|
||||
"note": note,
|
||||
}
|
||||
)
|
||||
return shares
|
||||
|
||||
|
||||
def split_rows(
|
||||
rows: Iterable[dict[str, Any]], shares: list[dict[str, Any]]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""암 줄을 몫마다 한 줄로 — 토사 줄은 그대로."""
|
||||
out: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
if row.get("ground") not in ROCK_GROUNDS:
|
||||
out.append(row)
|
||||
continue
|
||||
for share in shares:
|
||||
numbers = {
|
||||
key: row[key] * share["fraction"]
|
||||
for key in SHARED_KEYS
|
||||
if isinstance(row.get(key), (int, float))
|
||||
}
|
||||
out.append(
|
||||
{
|
||||
**row,
|
||||
**numbers,
|
||||
"ground": share["ground"],
|
||||
"rock_class": share["rock_class"],
|
||||
"rock_split_note": share["note"],
|
||||
"blocked_kind": BLOCKED_INPUT_MISSING if share["blocked_reason"] else None,
|
||||
"blocked_reason": share["blocked_reason"],
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def split_amounts(
|
||||
by_ground: dict[str, float], shares: list[dict[str, Any]] | None
|
||||
) -> list[tuple[str, float, str, str | None, str]]:
|
||||
"""갈래별 물량(사토) → `(갈래, 물량, 막힘 사유, 암 갈래, 원래 이름)`. 몫이 없으면 그대로."""
|
||||
out = []
|
||||
for label, amount in sorted(by_ground.items()):
|
||||
if shares and label in ROCK_GROUNDS:
|
||||
out.extend(
|
||||
(s["ground"], amount * s["fraction"], s["blocked_reason"], s["rock_class"], label)
|
||||
for s in shares
|
||||
)
|
||||
else:
|
||||
out.append((label, amount, "", None, label))
|
||||
return out
|
||||
|
||||
|
||||
def apply_rock_split(
|
||||
haul: dict[str, Any],
|
||||
classes: Iterable[str],
|
||||
ratios_pct: dict[str, Any],
|
||||
methods: dict[str, str | None],
|
||||
) -> None:
|
||||
"""운반표(`rows`)를 자리에서 가르고 사토가 같은 몫을 쓰게 `rock_shares` 를 싣는다."""
|
||||
shares = rock_shares(classes, ratios_pct, methods)
|
||||
haul["rows"] = split_rows(haul.get("rows") or [], shares)
|
||||
haul["rock_shares"] = shares
|
||||
@@ -134,6 +134,24 @@ def totals(rows: list[SlopeAreaRow]) -> dict[str, float]:
|
||||
return {key: sum(row.areas.get(key, 0.0) for row in rows) for key in keys}
|
||||
|
||||
|
||||
def cut_material_totals(
|
||||
slopes: Iterable[StationSlope], ratios: SlopeRatios | None = None
|
||||
) -> dict[str, float]:
|
||||
"""면고르기 절토면의 토사·암 몫 면적 — 같은 평균단면적법 · 같은 반영률(합 ≤ 절토면 전체)."""
|
||||
rate = (ratios or SlopeRatios()).of("face_dressing")
|
||||
ordered = sorted(slopes, key=lambda s: s.chainage_m)
|
||||
result = {"face_dressing_cut_soil": 0.0, "face_dressing_cut_rock": 0.0}
|
||||
for before, after in zip(ordered, ordered[1:]):
|
||||
distance = after.chainage_m - before.chainage_m
|
||||
for key, field_name in (
|
||||
("face_dressing_cut_soil", "cut_soil_length_m"),
|
||||
("face_dressing_cut_rock", "cut_rock_length_m"),
|
||||
):
|
||||
pair = getattr(before, field_name) + getattr(after, field_name)
|
||||
result[key] += pair / 2.0 * distance * rate
|
||||
return result
|
||||
|
||||
|
||||
def unclosed_stations(rows: list[SlopeAreaRow]) -> list[float]:
|
||||
"""사면이 원지반을 못 만나 **면적이 잘린** 측점 목록.
|
||||
|
||||
@@ -148,6 +166,7 @@ def build_table(
|
||||
) -> dict[str, Any]:
|
||||
"""화면·API 가 그대로 쓰는 모양."""
|
||||
rates = ratios or SlopeRatios()
|
||||
slopes = list(slopes)
|
||||
rows = build_rows(slopes, rates)
|
||||
return {
|
||||
"method": "average_end_area",
|
||||
@@ -166,7 +185,7 @@ def build_table(
|
||||
}
|
||||
for row in rows
|
||||
],
|
||||
"totals": totals(rows),
|
||||
"totals": {**totals(rows), **cut_material_totals(slopes, rates)},
|
||||
"unclosed_stations": unclosed_stations(rows),
|
||||
"station_count": len(rows),
|
||||
}
|
||||
|
||||
@@ -58,6 +58,10 @@ class StationSlope:
|
||||
chainage_m: float
|
||||
cut_length_m: float = 0.0
|
||||
fill_length_m: float = 0.0
|
||||
#: 절토 사면길이의 토사·암 몫 — 면고르기가 9-19-1(토사)·9-19-2·3(암)으로 갈림(2026-09-14).
|
||||
#: 둘의 합이 `cut_length_m` 보다 작으면 그 차이는 **못 가른 몫**(설계 지반 프리셋 없음).
|
||||
cut_soil_length_m: float = 0.0
|
||||
cut_rock_length_m: float = 0.0
|
||||
#: 층따기 밑수 — **원지반 표면**의 경사길이(m). B06 설계가 측점마다 낸다
|
||||
#: (`design.bench_cut_length_m`, 2026-09-09 랩탑 메인).
|
||||
#: ⚠ **성토 비탈면 길이와 다른 면이다** — 층따기는 성토부 **아래 원지반**을 계단으로
|
||||
@@ -69,6 +73,9 @@ class StationSlope:
|
||||
# ⚠ 좌우가 다르면 **큰 쪽**을 쓴다. 「중심점 성토고 5m 이상」(품셈 11-3 [주]①) 판정은
|
||||
# 가장 높은 쪽이 기준이고, 양쪽을 더하면 실제보다 두 배가 된다.
|
||||
fill_height_m: float = 0.0
|
||||
#: 노면 폭(m) — 노체 끝(`road_edges`) 좌우 사이. 표토제거 대상 「노면」 면적의 밑수(별표2).
|
||||
#: 노체 끝이 저장에 없으면 `None` — 0 으로 때우지 않는다.
|
||||
roadbed_width_m: float | None = None
|
||||
segments: tuple[SlopeSegment, ...] = ()
|
||||
# 사면이 샘플 범위 끝까지 원지반을 못 만나 **면적이 잘린** 측점.
|
||||
# 설계 엔진이 `slope_unclosed` 로 이미 경고하는 값을 그대로 물고 온다. 잘린 측점은
|
||||
@@ -207,6 +214,25 @@ def _material(design: dict[str, Any], ratio: float) -> str | None:
|
||||
return "rock" if abs(ratio - rock) < abs(ratio - soil) else "soil"
|
||||
|
||||
|
||||
def _roadbed_width(design: dict[str, Any]) -> float | None:
|
||||
"""노면 폭 — 노체 끝 좌(+)·우(−) 오프셋 사이. 한쪽이라도 없으면 `None`."""
|
||||
edges = design.get("road_edges") or {}
|
||||
left = _num((edges.get("left") or {}).get("offset_m"))
|
||||
right = _num((edges.get("right") or {}).get("offset_m"))
|
||||
return None if left is None or right is None else abs(left - right)
|
||||
|
||||
|
||||
def road_surface_area(slopes: Iterable[StationSlope]) -> float | None:
|
||||
"""노면 면적(㎡) — 사면과 같은 평균단면적법. 노면 폭이 빈 측점이 하나라도 있으면 `None`."""
|
||||
ordered = sorted(slopes, key=lambda s: s.chainage_m)
|
||||
if not ordered or any(s.roadbed_width_m is None for s in ordered):
|
||||
return None
|
||||
return sum(
|
||||
(float(a.roadbed_width_m) + float(b.roadbed_width_m)) / 2.0 * (b.chainage_m - a.chainage_m)
|
||||
for a, b in zip(ordered, ordered[1:])
|
||||
)
|
||||
|
||||
|
||||
def station_slope(chainage_m: float, design: dict[str, Any]) -> StationSlope:
|
||||
"""측점 하나의 사면길이. 좌우를 합쳐 절토·성토 각각의 총 사면길이를 낸다."""
|
||||
ratios = _ratios(design)
|
||||
@@ -218,13 +244,23 @@ 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,
|
||||
fill_height_m=max(fill_by_side.values(), default=0.0),
|
||||
roadbed_width_m=_roadbed_width(design),
|
||||
berm_width_m=_num(berm.get("width_m")) or 0.0,
|
||||
segments=tuple(segments),
|
||||
unclosed=bool(design.get("slope_unclosed")),
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
"""STmate 출력 엑셀 「일위대가표」 읽개 → **고정형** 라이브러리 항목 (PLAN 4장 · 2026-09-14 브레인 판정).
|
||||
|
||||
왜 엑셀인가 — STC 는 호표(B) 구성이 난독화된 BDQTY 에만 있어 못 읽음(7파일 전수) · 경쟁사 보호 장치는
|
||||
안 풂. 출력 엑셀은 평문이고 모양은 코덱스 `recipe_extract.py`(실무 6건 누락 0)가 본 그대로.
|
||||
|
||||
⚠ **모양이 다르면 억지로 읽지 않음** — 머리 칸·수량 칸이 어긋나면 「어느 칸이 안 맞는지」 사유로
|
||||
돌려보내고 그 호표는 통째로 뺌. 추측해서 맞추면 수량이 조용히 틀린 채 라이브러리에 들어가 계속 쓰임.
|
||||
⚠ 뽑는 것은 **수량만**(판정 Ⓔ) — 원문 단가·금액은 안 실음. 수량도 그 시점 품셈값이라 사유를 붙임.
|
||||
⚠ 한 단만(판정 Ⓕ) — 구성 줄이 하위 호표면 그 이름·수량만. 하위 전개는 우리 일위대가 몫.
|
||||
⚠ 원문 호표·자원 코드(B#####·M#####)는 파일 안 카운터라 **출처로 기록만**(판정 Ⓓ).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, BinaryIO
|
||||
|
||||
SHEET = "일위대가표"
|
||||
HEADER_ROW = 3
|
||||
HEADER = ("명칭", "규격", "수량", "단위")
|
||||
COLUMNS = "ABCD"
|
||||
HOPYO = re.compile(r"^제(\d+)호표$")
|
||||
ROW_CODE = re.compile(r"(?<!BD)COD\|([A-Z]\d{5})")
|
||||
HOPYO_CODE = re.compile(r"BDCOD\|(B\d{5})")
|
||||
#: 비고가 이것이면 별도계상 자재 — 자재총괄 자리(판정 ㉮). 그 밖의 비고로는 갈 곳을 안 가름.
|
||||
SEPARATE_MATERIAL = ("별산자재", "별산M")
|
||||
#: 착공·변경 내역서 판에만 섞이는 낙찰률 줄 — 설계 조합이 아니라 안 뽑음(판정 ㉰).
|
||||
#: 실무 6건 전수에서 단위 없는 줄은 이 다섯 꼴뿐: 합계 · 계 · 소계 · 계약단가 · 계 x 낙찰율(88 %).
|
||||
CONTRACT_ROWS = ("계약단가", "계x낙찰율")
|
||||
NOTE_SOURCE_QTY = "원문 시점 수량 · 현행 품셈 대조 전"
|
||||
NOTE_PERCENT = "원문에 가산 행 {count}줄 있었음({names}) — 우리 계산에는 안 듦"
|
||||
NOTE_CONTRACT = "원문 계약단가·낙찰율 줄 {count}줄은 설계 조합이 아니라 안 뽑음"
|
||||
|
||||
|
||||
def _text(value: Any) -> str:
|
||||
return "" if value is None else str(value).strip()
|
||||
|
||||
|
||||
def read_recipes(path: str | Path | BinaryIO) -> dict[str, Any]:
|
||||
"""`{"project": 공사명, "hopyo": [호표…], "problems": [사유…]}` — 못 읽은 호표는 목록에 없고 사유만."""
|
||||
import openpyxl
|
||||
|
||||
problems: list[str] = []
|
||||
try:
|
||||
book = openpyxl.load_workbook(path, data_only=True, read_only=True)
|
||||
except Exception as error: # noqa: BLE001 — 어떤 파일이든 사유로 돌려보냄
|
||||
return {"project": "", "hopyo": [], "problems": [f"엑셀을 못 엶 — {error}"]}
|
||||
if SHEET not in book.sheetnames:
|
||||
return {
|
||||
"project": "",
|
||||
"hopyo": [],
|
||||
"problems": [f"시트 「{SHEET}」가 없음 — STmate 출력 엑셀이 아님"],
|
||||
}
|
||||
rows = [list(r) + [None] * 14 for r in book[SHEET].iter_rows(max_col=14, values_only=True)]
|
||||
head = rows[HEADER_ROW - 1] if len(rows) >= HEADER_ROW else [None] * 14
|
||||
for column, want, got in zip(COLUMNS, HEADER, head):
|
||||
if _text(got).replace(" ", "") != want:
|
||||
problems.append(
|
||||
f"{column}{HEADER_ROW} 칸이 「{want}」이 아니라 「{_text(got)}」 — 이 모양은 못 읽음"
|
||||
)
|
||||
if problems:
|
||||
return {"project": "", "hopyo": [], "problems": problems}
|
||||
project = _text(rows[1][0]).split(":", 1)[-1].strip() if len(rows) > 1 else ""
|
||||
|
||||
hopyo: list[dict[str, Any]] = []
|
||||
current: dict[str, Any] | None = None
|
||||
|
||||
def close() -> None:
|
||||
if current and not current["broken"]:
|
||||
if current["name"]:
|
||||
hopyo.append({k: v for k, v in current.items() if k != "broken"})
|
||||
else:
|
||||
problems.append(f"제{current['no']}호표 제목 줄이 없음 — 안 읽음")
|
||||
|
||||
for index, row in enumerate(rows[HEADER_ROW:], start=HEADER_ROW + 1):
|
||||
name, spec, qty, unit = _text(row[0]), _text(row[1]), row[2], _text(row[3])
|
||||
key = name.replace(" ", "")
|
||||
found = HOPYO.match(key)
|
||||
if found:
|
||||
close()
|
||||
code = HOPYO_CODE.search(_text(row[13]))
|
||||
current = {
|
||||
"no": int(found.group(1)),
|
||||
"source_code": code.group(1) if code else "",
|
||||
"name": "",
|
||||
"spec": "",
|
||||
"unit": "",
|
||||
"rows": [],
|
||||
"basis": [],
|
||||
"contract_rows": 0,
|
||||
"broken": False,
|
||||
}
|
||||
continue
|
||||
if key.startswith("합계"):
|
||||
close()
|
||||
current = None
|
||||
continue
|
||||
if current is None or current["broken"] or not any(_text(c) for c in row[:4]):
|
||||
continue
|
||||
where = f"제{current['no']}호표"
|
||||
if not current["name"]:
|
||||
if not name or qty not in (None, "") or not unit:
|
||||
problems.append(
|
||||
f"A{index}: {where} 제목 줄(명칭·단위 · 수량 빈칸) 모양이 아님 — 안 읽음"
|
||||
)
|
||||
current["broken"] = True
|
||||
else:
|
||||
current.update(name=name, spec=spec, unit=unit)
|
||||
continue
|
||||
if not unit and (key == "계" or key.startswith("소계")):
|
||||
continue # 착공·변경 판 소계 줄 — 구성 아님
|
||||
if not unit and key.startswith(CONTRACT_ROWS):
|
||||
current["contract_rows"] += 1
|
||||
continue
|
||||
if not unit and qty == 0 and not isinstance(qty, bool):
|
||||
current["basis"].append(f"{name} {spec}".strip()) # 품셈 근거 줄 — 수량 0 · 단위 없음
|
||||
continue
|
||||
if isinstance(qty, bool) or not isinstance(qty, (int, float)):
|
||||
problems.append(
|
||||
f"C{index}: {where} 「{name}」 수량 「{_text(qty)}」이 수가 아님 — 안 읽음"
|
||||
)
|
||||
current["broken"] = True
|
||||
continue
|
||||
if not name or not unit:
|
||||
empty = "명칭" if not name else "단위"
|
||||
problems.append(
|
||||
f"{'A' if not name else 'D'}{index}: {where} 「{name}」 {empty}이 빔 — 이 모양은 못 읽음"
|
||||
)
|
||||
current["broken"] = True
|
||||
continue
|
||||
code = ROW_CODE.search(_text(row[13]))
|
||||
current["rows"].append(
|
||||
{
|
||||
"name": name,
|
||||
"spec": spec,
|
||||
"amount": qty,
|
||||
"unit": unit,
|
||||
"remark": _text(row[12]),
|
||||
"source_code": code.group(1) if code else "",
|
||||
}
|
||||
)
|
||||
close()
|
||||
return {"project": project, "hopyo": hopyo, "problems": problems}
|
||||
|
||||
|
||||
def recipe_item(
|
||||
hopyo: dict[str, Any], *, type_id: str, file_name: str, project: str
|
||||
) -> dict[str, Any]:
|
||||
"""읽은 호표 하나 → 고정형 라이브러리 항목(명세 13장 칸 계약 · `formula` 빈칸 = 고정형). 코드는 저장이 발급."""
|
||||
basis = " · ".join(hopyo.get("basis") or [])
|
||||
rows = []
|
||||
percent = []
|
||||
for seq, source in enumerate(hopyo["rows"], start=1):
|
||||
is_percent = source["unit"] == "%"
|
||||
if is_percent:
|
||||
percent.append(source["name"])
|
||||
separate = any(mark in source["remark"].replace(" ", "") for mark in SEPARATE_MATERIAL)
|
||||
rows.append(
|
||||
{
|
||||
"seq": seq,
|
||||
"name": source["name"],
|
||||
"spec": source["spec"],
|
||||
"formula": "",
|
||||
"formula_text": " · ".join(part for part in ("STmate 원문 수량", basis) if part),
|
||||
"amount": str(source["amount"]),
|
||||
"unit": source["unit"],
|
||||
# 원문에서 구성 줄은 그 호표 단가 안으로 들어감(호표 = 일위대가) · 별도계상만 자재총괄(판정 ㉮)
|
||||
"destination": "reference"
|
||||
if is_percent
|
||||
else "material"
|
||||
if separate
|
||||
else "unit_price",
|
||||
"rounding": {"mode": "none", "digits": 0},
|
||||
"source": "library",
|
||||
"reason": "가산 행 — 수량 계산에 안 씀" if is_percent else NOTE_SOURCE_QTY,
|
||||
}
|
||||
)
|
||||
# ㉮ 일위대가 구성 — 일위대가로 가는 줄마다 한 줄(수량 = 그 원단위 줄). **공종 코드는 비움**:
|
||||
# 원문 코드는 파일 카운터라 못 쓰고 이름으로 확정도 안 함(명세 2장) → 사용자가 고르개로 고름.
|
||||
# 고르기 전엔 그 장 금액이 「미완 — 공종 코드 미정」(조용한 0 아님 · 2026-09-14 판정).
|
||||
unit_rows = [
|
||||
{
|
||||
"seq": index,
|
||||
"name": row["name"],
|
||||
"spec": row["spec"],
|
||||
"unit": row["unit"],
|
||||
"from_row": row["seq"],
|
||||
}
|
||||
for index, row in enumerate(
|
||||
(row for row in rows if row["destination"] == "unit_price"), start=1
|
||||
)
|
||||
]
|
||||
notes = [NOTE_SOURCE_QTY]
|
||||
if percent:
|
||||
notes.append(NOTE_PERCENT.format(count=len(percent), names=" · ".join(percent)))
|
||||
if hopyo.get("contract_rows"):
|
||||
notes.append(NOTE_CONTRACT.format(count=hopyo["contract_rows"]))
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"item_kind": "fixed",
|
||||
"type_id": type_id,
|
||||
"name": f"{hopyo['name']} {hopyo['spec']}".strip(),
|
||||
"unit": hopyo["unit"],
|
||||
"note": " · ".join(notes),
|
||||
"vars": {},
|
||||
"tables": {},
|
||||
"rows": rows,
|
||||
"unit_price": {
|
||||
"unit": hopyo["unit"],
|
||||
"note": "STmate 호표 구성 — 줄마다 공종 코드를 고르개로 골라야 금액이 섬(원문 코드는 안 씀)",
|
||||
"rows": unit_rows,
|
||||
},
|
||||
# 출처 — 원문 공사명·파일명이 들어감. 공유 만들 때 뺄지·가릴지 판정(PLAN 4장).
|
||||
"origin": {
|
||||
"kind": "stmate_xlsx",
|
||||
"file": file_name,
|
||||
"project": project,
|
||||
"hopyo_no": hopyo["no"],
|
||||
"hopyo_code": hopyo["source_code"],
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
"""구조물도 **상단 그림** — 돌쌓기 단면 (PLAN 12장 · 3장 한 장의 짜임 「상단 그림」).
|
||||
|
||||
⚠ **2026-09-14 B07 에서 이관** — 옛 자리 `B07_DesignDetail_Engine_Cad_StandardFigure.py`.
|
||||
CAD 엔티티 대신 **모양 목록(선·글자, m 단위, 위가 +y)** 을 냄 — 화면이 SVG 로 그리고(읽기 전용),
|
||||
CAD 는 뒤 일감이 같은 목록을 받아 씀(B07 `StandardSheet` 가 이미 옮겨 적음).
|
||||
|
||||
⚠ **기울기를 값으로 받는다 — 0.3 을 박지 않는다.**
|
||||
판정은 B08 `slope_of()` 한 벌(품셈 13-4-4 [주]⑪ 표준경사 — 직고·메찰·성절토). 사용자가 정했으면
|
||||
그 값이 이긴다(확정 ⑨). 그림은 **수량이 쓴 바로 그 값**으로 기울고 근거 문구도 같이 적는다.
|
||||
|
||||
⚠ **두께는 수량표가 쓰는 그 함수**(`UnitQuantity.wall_thickness`) — 사용자가 넣은 상부·하부 두께가
|
||||
식을 이긴다. 옛 B07 그림은 식을 따로 적어 **두께를 넣어도 그림만 옛 두께**로 섰음(이관 때 잡음).
|
||||
|
||||
상부 두께 = 뒷길이 + 0.30 하부 두께 = 상부 + 0.30 × (H − 1.0)
|
||||
터파기 폭 = 평균두께 + 0.2 기초 0.5×0.9 (기초유) · 0.1×0.7 (기초버림)
|
||||
|
||||
⚠ 터파기 치수는 **`common_util_excavation` 한 벌**을 읽는다 — 횡단도가 쓰는 그 상수다.
|
||||
⚠ 뒷길이를 안 정한 장은 **표가 선 그 기본값으로 그리고 표 사유와 같은 말을 적는다**
|
||||
(2026-09-14 브레인 판정 — 표가 서면 그림도 섬). 까닭은 정말 못 그리는 장(큰돌쌓기 등)만.
|
||||
|
||||
⚠ **치수가 없는 것은 그리되 치수를 적지 않는다** — 막자갈(뒷채움)은 우리 식이 「입적에서
|
||||
몸통·고임돌을 뺀 것」이라 폭·높이로 정의된 도형이 아니다. 자리만 보이고 값은 표를 가리킨다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from B08_Quantity.B08_Quantity_Engine_Pipe import NOTE_DEFAULT_WALL
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureSheet import sheet_is_wet, slope_of
|
||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import (
|
||||
_back_length,
|
||||
back_length_default_note,
|
||||
wall_thickness,
|
||||
weep_hole_spec,
|
||||
)
|
||||
from common_util.common_util_excavation import (
|
||||
WALL_BLINDING_DEPTH_M,
|
||||
WALL_BLINDING_WIDTH_M,
|
||||
WALL_FOUNDATION_DEPTH_M,
|
||||
WALL_FOUNDATION_WIDTH_M,
|
||||
WALL_TRENCH_CLEARANCE_M,
|
||||
)
|
||||
|
||||
#: 그림 축척 — 1/25(1m = 40㎜). CAD 로 옮길 때 글자 크기(㎜)를 m 로 바꾸는 데도 씀.
|
||||
SCALE_MM_PER_M = 40.0
|
||||
|
||||
#: 이 그림을 그리는 종류. 집수정처럼 단면이 다른 것은 아직 그리지 않는다.
|
||||
#: ⚠ **큰돌쌓기(`boulder_masonry`)를 뺐다**(2026-09-09). 그림이 두께를 **뒷길이**에서 내는데
|
||||
#: 큰돌쌓기는 규격 축이 **직경**(`stone_cm`)이라 **영영 못 그린다** — 품셈 13-6(직경) ↔
|
||||
#: 13-4(뒷길이). 「직경에서 두께를 내는 식」은 도메인 판단이라 사용자 몫.
|
||||
#: ⓘ 옹벽(2026-09-14)은 **관측 원단위 줄에 실린 단면 치수**로 그림 — 표 수량이 나온 그 단면.
|
||||
#: 기슭막이는 형태가 돌쌓기면 **돌쌓기 식 그대로**라 그림도 같음(표가 안 선 형태는 표 사유가 까닭).
|
||||
MASONRY_TYPE_IDS: frozenset[str] = frozenset({"masonry_wet", "masonry_dry", "revetment"})
|
||||
#: 정본 개소당·㎡당 구조물 — `_StructureFigure_Small`(치수는 표가 쓴 한 벌).
|
||||
SMALL_TYPE_IDS: frozenset[str] = frozenset({"erosion_check", "soil_guard", "bed_sill"})
|
||||
#: 관측 원단위 줄의 `section`(표 수량이 나온 그 단면)으로 그리는 종류 — 단면 없는 규격은 까닭.
|
||||
OBSERVED_TYPE_IDS: frozenset[str] = frozenset(
|
||||
{"retaining_wall", "pipe_inlet_basin", "pipe_wing_wall", "ford_pavement"}
|
||||
)
|
||||
FIGURE_TYPE_IDS: frozenset[str] = MASONRY_TYPE_IDS | SMALL_TYPE_IDS | OBSERVED_TYPE_IDS
|
||||
|
||||
#: 개거 — 표는 서는데 그림을 못 정한 까닭(2026-09-14 원문 대조).
|
||||
_OPEN_DITCH_REASONS: dict[str, str] = {
|
||||
"콘크리트 개거 150×200": (
|
||||
"그림 없음 — 원문 둘이 다름(산출식 0.3×0.1 ↔ 그림 홈 150×200 + 밑 보 400×200)"
|
||||
" · 같은 탭 「개거(150-200)」 안에서 갈림"
|
||||
" · 어느 쪽으로 그릴지 확인 대기(임의로 고르지 않음)"
|
||||
),
|
||||
"L형수로 H=0.2": (
|
||||
"그림 없음 — 원문 「L형수로-(201)」 탭에 단면 그림이 없어"
|
||||
" 산출식만으로 모양을 정하지 않습니다"
|
||||
),
|
||||
}
|
||||
|
||||
#: 그림이 못 서는 장에 **까닭을 적는다** — 빈 자리를 그냥 두면 사용자가 「고장」으로 읽는다
|
||||
#: (2026-09-09 사용자 지시). 「무엇을 받아야 서는지」까지 적는다.
|
||||
_NO_FIGURE_REASONS: dict[str, str] = {
|
||||
"boulder_masonry": (
|
||||
"그림 없음 — 큰돌쌓기는 규격이 **직경**이라 두께를 낼 식이 아직 없습니다"
|
||||
"(뒷길이로 내는 돌쌓기 식과 축이 다름 · 사용자 확정 대기)"
|
||||
),
|
||||
}
|
||||
_NO_FIGURE_DEFAULT = "그림 없음 — 이 형식은 아직 단면을 그리지 않습니다"
|
||||
|
||||
#: 글자 크기(m) — 종이 ㎜ 를 축척으로 나눈 값(옛 CAD 그림과 같은 크기).
|
||||
_LABEL_SIZE = 4.0 / SCALE_MM_PER_M
|
||||
_DIM_SIZE = 3.4 / SCALE_MM_PER_M
|
||||
|
||||
|
||||
def back_length_cm(sheet: dict[str, Any]) -> tuple[int, str | None]:
|
||||
"""(표가 쓴 뒷길이 ㎝, 기본으로 섰으면 표 사유와 같은 한 줄).
|
||||
|
||||
양식 장은 양식이 푼 값(`formula_sheet.vars.L3`), 전개 장은 `_back_length` — 표와 한 벌.
|
||||
"""
|
||||
options = sheet.get("options") or {}
|
||||
wet, height = sheet_is_wet(sheet), float(sheet.get("height_m") or 0.0)
|
||||
solved = ((sheet.get("formula_sheet") or {}).get("vars") or {}).get("L3")
|
||||
back = (
|
||||
int(float(solved))
|
||||
if isinstance(solved, (int, float))
|
||||
else _back_length(options, wet=wet, height_m=height)
|
||||
)
|
||||
return back, back_length_default_note(options, back, wet=wet, height_m=height)
|
||||
|
||||
|
||||
def figure_reason(sheet: dict[str, Any]) -> str | None:
|
||||
"""그림이 **안 서는 까닭** 한 줄. 서는 장이면 `None`."""
|
||||
type_id = str(sheet.get("type_id") or "")
|
||||
if "rows" in sheet and not sheet["rows"]:
|
||||
# 표가 안 선 장 — 표가 적은 그 사유를 그대로(표 = 그림 · 같은 말).
|
||||
notes = [str(note).replace("**", "") for note in sheet.get("notes") or []]
|
||||
return f"그림 없음 — {notes[0] if notes else '표가 서지 않았습니다'}"
|
||||
if type_id == "open_ditch":
|
||||
spec = str((sheet.get("options") or {}).get("ditch_spec") or "콘크리트 개거 150×200")
|
||||
return _OPEN_DITCH_REASONS.get(spec, _NO_FIGURE_DEFAULT)
|
||||
if type_id not in FIGURE_TYPE_IDS:
|
||||
return _NO_FIGURE_REASONS.get(type_id, _NO_FIGURE_DEFAULT)
|
||||
if type_id in SMALL_TYPE_IDS:
|
||||
return None # 치수가 없으면 표가 안 서고, 그때는 위 「표가 안 선 장」이 사유를 냄
|
||||
if type_id in OBSERVED_TYPE_IDS:
|
||||
return _observed_section(sheet)[1]
|
||||
if float(sheet.get("height_m") or 0.0) <= 0:
|
||||
return "그림 없음 — 높이가 없습니다(제원에서 높이를 넣으면 그림이 섭니다)"
|
||||
return None
|
||||
|
||||
|
||||
def _observed_section(sheet: dict[str, Any]) -> tuple[dict[str, Any] | None, str | None]:
|
||||
"""(관측 원단위 줄, 못 그리는 까닭) — 표가 줄을 꺼낸 **그 조회**(`OBSERVED_SPEC_KEYS`)를 씀.
|
||||
|
||||
표가 안 선 장은 표 사유와 같은 말 앞에 「그림 없음」만 붙임.
|
||||
"""
|
||||
from B08_Quantity.B08_Quantity_Engine_ObservedUnit import load_observed_table
|
||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import OBSERVED_SPEC_KEYS
|
||||
from B08_Quantity.B08_Quantity_Wording import option_missing, spec_missing
|
||||
|
||||
type_id = str(sheet.get("type_id") or "")
|
||||
keys = OBSERVED_SPEC_KEYS.get(type_id) or ()
|
||||
options = sheet.get("options") or {}
|
||||
spec = {key: options[key] for key in keys if options.get(key) is not None}
|
||||
table = load_observed_table()
|
||||
if not spec:
|
||||
return None, f"그림 없음 — {option_missing(keys[0], type_id)}"
|
||||
entry = table.find(type_id, spec)
|
||||
if entry is None:
|
||||
return None, f"그림 없음 — {spec_missing(type_id, table.specs_for(type_id))}"
|
||||
if not entry.get("section"):
|
||||
why = _NO_SECTION_REASONS.get(type_id) or _NO_SECTION_BY_SOURCE.get(
|
||||
str(entry.get("source") or ""), "이 규격은 자료에 수량만 있고 단면 치수가 없습니다"
|
||||
)
|
||||
return None, f"그림 없음 — {why}"
|
||||
return entry, None
|
||||
|
||||
|
||||
#: 관측 줄에 단면이 없는 까닭 — 종류별(원문을 봤는데 못 정한 자리).
|
||||
_NO_SECTION_REASONS: dict[str, str] = {
|
||||
"pipe_wing_wall": (
|
||||
"원문 「날개벽(…)산출근거」 탭에 구조도 그림이 없고(「구조도 참조」만) 날개 모양이"
|
||||
" 산출식에 손으로 적은 수로만 있어 모양을 정하지 않음 — 구조도 원본 확보 대기"
|
||||
),
|
||||
}
|
||||
#: 출처별 — 수량 집계표 관측값은 치수 원문 자체가 없음.
|
||||
_NO_SECTION_BY_SOURCE: dict[str, str] = {
|
||||
"uljin_compare": "수량 집계표 관측값(울진 1공구)이라 구조도·치수 원문이 없습니다",
|
||||
}
|
||||
|
||||
|
||||
def _path(points: list[tuple[float, float]], role: str, *, dash: bool = False) -> dict[str, Any]:
|
||||
return {"kind": "path", "points": [list(p) for p in points], "role": role, "dash": dash}
|
||||
|
||||
|
||||
def _text(
|
||||
text: str, at: tuple[float, float], align: str, size: float = _DIM_SIZE
|
||||
) -> dict[str, Any]:
|
||||
return {"kind": "text", "text": text, "at": list(at), "align": align, "size": size}
|
||||
|
||||
|
||||
def build_figure(
|
||||
sheet: dict[str, Any], rubble_thickness_m: float | None = None
|
||||
) -> list[dict[str, Any]] | None:
|
||||
"""장 하나의 그림 — 모양 목록(밑면 앞끝이 원점). 못 그리면 `None`(까닭은 `figure_reason`).
|
||||
|
||||
`rubble_thickness_m` — 산출 조건 기초잡석 두께(표의 기초잡석 줄과 같은 값 · 비면 기본).
|
||||
"""
|
||||
if figure_reason(sheet) is not None:
|
||||
return None
|
||||
type_id = sheet.get("type_id")
|
||||
if type_id == "retaining_wall":
|
||||
entry, _reason = _observed_section(sheet)
|
||||
return _wall_figure(sheet, entry or {}, rubble_thickness_m)
|
||||
if type_id in ("pipe_inlet_basin", "ford_pavement"):
|
||||
from B08_Quantity import B08_Quantity_Engine_StructureFigure_Small as small
|
||||
|
||||
entry, _reason = _observed_section(sheet)
|
||||
if type_id == "pipe_inlet_basin":
|
||||
return small.inlet_basin_figure(sheet, entry or {})
|
||||
return small.pavement_figure(sheet, entry or {})
|
||||
if type_id in SMALL_TYPE_IDS:
|
||||
from B08_Quantity import B08_Quantity_Engine_StructureFigure_Small as small
|
||||
|
||||
if type_id == "bed_sill":
|
||||
return small.bed_sill_figure(sheet, rubble_thickness_m)
|
||||
if type_id == "erosion_check":
|
||||
return small.erosion_check_figure(sheet)
|
||||
return small.soil_guard_figure(sheet)
|
||||
return _masonry_figure(sheet)
|
||||
|
||||
|
||||
def _masonry_figure(sheet: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""돌쌓기 단면 — 두께는 수량표 함수 한 벌."""
|
||||
options = sheet.get("options") or {}
|
||||
height_m = float(sheet.get("height_m") or 0.0)
|
||||
back_cm, default_note = back_length_cm(sheet)
|
||||
slope, slope_note = slope_of(sheet)
|
||||
top_t, bottom_t, _basis = wall_thickness(options, back_cm=back_cm, height_m=height_m)
|
||||
lean = slope * height_m
|
||||
average_t = (top_t + bottom_t) / 2.0
|
||||
dig_width = average_t + WALL_TRENCH_CLEARANCE_M
|
||||
# 기초 몫 — 「기초유 / 기초버림」이 폭·깊이를 가른다(정본 탭 제목). 안 정한 장은 안 그린다.
|
||||
foundation = str(options.get("foundation") or "")
|
||||
base_w, base_d = {
|
||||
"기초유": (WALL_FOUNDATION_WIDTH_M, WALL_FOUNDATION_DEPTH_M),
|
||||
"기초버림": (WALL_BLINDING_WIDTH_M, WALL_BLINDING_DEPTH_M),
|
||||
}.get(foundation, (0.0, 0.0))
|
||||
|
||||
shapes = [
|
||||
_path(
|
||||
[(0.0, 0.0), (lean, height_m), (lean + top_t, height_m), (bottom_t, 0.0), (0.0, 0.0)],
|
||||
"wall",
|
||||
),
|
||||
# 터파기 — 수량식 H × (평균두께 + 0.2) 와 **넓이가 같은** 평행사변형(앞면 따라 기움).
|
||||
_path(
|
||||
[
|
||||
(0.0, 0.0),
|
||||
(lean, height_m),
|
||||
(lean + dig_width, height_m),
|
||||
(dig_width, 0.0),
|
||||
(0.0, 0.0),
|
||||
],
|
||||
"guide",
|
||||
dash=True,
|
||||
),
|
||||
]
|
||||
if base_d > 0:
|
||||
shapes.append(
|
||||
_path([(0.0, 0.0), (0.0, -base_d), (base_w, -base_d), (base_w, 0.0)], "guide")
|
||||
)
|
||||
# 물구멍 — 벽을 가로지르는 짧은 선. 값은 **그 장의 제원**에서(수량표와 같은 함수).
|
||||
weep_area_m2, weep_diameter_mm, _weep_basis = weep_hole_spec(options)
|
||||
weep_y = height_m * 0.5
|
||||
shapes.append(_path([(slope * weep_y, weep_y), (slope * weep_y + top_t, weep_y)], "guide"))
|
||||
|
||||
right = max(lean + dig_width, base_w) + 0.16
|
||||
shapes += [
|
||||
_text(f"H = {height_m:g} m", (-0.28, height_m / 2.0), "right"),
|
||||
_text(f"상부 {top_t:.2f} m", (lean + top_t / 2.0, height_m + 0.14), "center"),
|
||||
_text(f"하부 {bottom_t:.2f} m", (bottom_t / 2.0, -0.30 - base_d), "center"),
|
||||
_text(f"1 : {slope:g}", (lean / 2.0 - 0.30, height_m * 0.72), "right"),
|
||||
_text(
|
||||
f"터파기 폭 {dig_width:.2f} m "
|
||||
f"(평균두께 {average_t:.2f} + {WALL_TRENCH_CLEARANCE_M:g})",
|
||||
(right, height_m * 0.92),
|
||||
"left",
|
||||
),
|
||||
_text(
|
||||
f"뒷길이 ℓ₃ = {back_cm}㎝{' (기본)' if default_note else ''}",
|
||||
(right, height_m * 0.72),
|
||||
"left",
|
||||
),
|
||||
_text("막자갈(뒷채움) — 수량은 아래 표", (right, height_m * 0.55), "left"),
|
||||
_text(
|
||||
f"물구멍 Ø{weep_diameter_mm} — {weep_area_m2:g}㎡당 1개소",
|
||||
(right, weep_y - 0.2),
|
||||
"left",
|
||||
),
|
||||
_text(
|
||||
f"기초 {base_w:g} × {base_d:g} m ({foundation})"
|
||||
if base_d > 0
|
||||
else "기초 — 안 정함(기초유/기초버림)",
|
||||
(right, -base_d / 2.0 if base_d else 0.0),
|
||||
"left",
|
||||
),
|
||||
# 기울기 근거는 그림 밑에 한 줄 — 「사용자 지정」인지 「품셈 표준경사」인지 보여야 한다.
|
||||
_text(f"전면 기울기 — {slope_note}", (0.0, -0.62 - base_d), "left"),
|
||||
_text(
|
||||
f"{sheet.get('title') or '돌쌓기'} (축척 1/{int(1000 / SCALE_MM_PER_M)})",
|
||||
(0.0, height_m + 0.52),
|
||||
"left",
|
||||
_LABEL_SIZE,
|
||||
),
|
||||
]
|
||||
# 「이 값이 어디서 왔나」 — 표 사유 줄과 **글자까지 같게**(브레인 판정). 벽 치수 자체가
|
||||
# 기본값인 관 지점 기슭막이는 그 줄도 — 그림이 설계값처럼 보이면 더 위험함(2026-09-14).
|
||||
wall_prefix = NOTE_DEFAULT_WALL.split("{")[0]
|
||||
lines = [default_note] + [
|
||||
str(n) for n in sheet.get("notes") or [] if str(n).startswith(wall_prefix)
|
||||
]
|
||||
for index, line in enumerate(line for line in lines if line):
|
||||
shapes.append(_text(line, (0.0, -0.80 - base_d - index * 0.18), "left"))
|
||||
return shapes
|
||||
|
||||
|
||||
def _wall_figure(
|
||||
sheet: dict[str, Any], entry: dict[str, Any], rubble_thickness_m: float | None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""옹벽 단면 — 관측 원단위 줄의 `section`(표 수량이 나온 그 단면)으로 그림.
|
||||
|
||||
x 는 앞굽 끝이 0(앞면 왼쪽), y 는 기초 밑면이 0. 버림·기초잡석은 기초 밑, 전단키가 뚫고 내려감.
|
||||
⚠ 기초잡석 두께는 표의 기초잡석 줄과 같은 산출 조건 값(비면 기본) — 폭은 버림과 같음.
|
||||
"""
|
||||
from B08_Quantity.B08_Quantity_Engine_ObservedUnit import earthwork_missing_note
|
||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import RUBBLE_BASE_THICKNESS_M
|
||||
|
||||
section = entry["section"]
|
||||
footing, wall, key = section["footing"], section["wall"], section["key"]
|
||||
blinding, weep = section["blinding"], section["weep"]
|
||||
f_w, f_t, toe = footing["width_m"], footing["thickness_m"], footing["toe_m"]
|
||||
w_h, w_top = wall["height_m"], wall["top_m"]
|
||||
front, back = wall["front_batter_m"], wall["back_batter_m"]
|
||||
w_bottom = w_top + front + back
|
||||
heel = f_w - toe - w_bottom
|
||||
k_w, k_d = key["width_m"], key["depth_m"]
|
||||
k_left = f_w - key["from_heel_m"] - k_w
|
||||
b_t, over = blinding["thickness_m"], blinding["overhang_m"]
|
||||
rubble = RUBBLE_BASE_THICKNESS_M if rubble_thickness_m is None else float(rubble_thickness_m)
|
||||
top = f_t + w_h
|
||||
|
||||
shapes = [
|
||||
_path(
|
||||
[
|
||||
(0.0, 0.0),
|
||||
(0.0, f_t),
|
||||
(toe, f_t),
|
||||
(toe + front, top),
|
||||
(toe + front + w_top, top),
|
||||
(toe + w_bottom, f_t),
|
||||
(f_w, f_t),
|
||||
(f_w, 0.0),
|
||||
(k_left + k_w, 0.0),
|
||||
(k_left + k_w, -k_d),
|
||||
(k_left, -k_d),
|
||||
(k_left, 0.0),
|
||||
(0.0, 0.0),
|
||||
],
|
||||
"wall",
|
||||
),
|
||||
]
|
||||
# 버림(키 자리는 비움) · 그 밑 기초잡석(같은 폭).
|
||||
layers = [(0.0, b_t, False)] + ([(b_t, rubble, True)] if rubble > 0 else [])
|
||||
for depth, thickness, dash in layers:
|
||||
for x0, x1 in ((-over, k_left), (k_left + k_w, f_w + over)):
|
||||
shapes.append(
|
||||
_path(
|
||||
[
|
||||
(x0, -depth),
|
||||
(x0, -depth - thickness),
|
||||
(x1, -depth - thickness),
|
||||
(x1, -depth),
|
||||
],
|
||||
"guide",
|
||||
dash=dash,
|
||||
)
|
||||
)
|
||||
weep_y = f_t + w_h * 0.5
|
||||
ratio = (weep_y - f_t) / w_h
|
||||
shapes.append(
|
||||
_path([(toe + front * ratio, weep_y), (toe + w_bottom - back * ratio, weep_y)], "guide")
|
||||
)
|
||||
# 터파기 — 표 줄과 같은 식(기초 폭 + 양쪽 여유 · 수직 · 기초 윗면에서 기초잡석 밑까지). 점선.
|
||||
trench = section.get("trench")
|
||||
clearance = trench["clearance_m"] if trench else 0.0
|
||||
side = (trench or {}).get("side_slope", 0.0) * (f_t + b_t + rubble)
|
||||
dig_bottom = -(b_t + rubble)
|
||||
if trench:
|
||||
shapes.append(
|
||||
_path(
|
||||
[
|
||||
(-clearance - side, f_t),
|
||||
(-clearance, dig_bottom),
|
||||
(f_w + clearance, dig_bottom),
|
||||
(f_w + clearance + side, f_t),
|
||||
],
|
||||
"guide",
|
||||
dash=True,
|
||||
)
|
||||
)
|
||||
|
||||
right = max(f_w + over, f_w + clearance + side) + 0.2
|
||||
weep_area, weep_dia, _weep_basis = weep_hole_spec(sheet.get("options") or {})
|
||||
blinding_width = f_w + 2 * over - k_w
|
||||
shapes += [
|
||||
_text(
|
||||
f"{sheet.get('title') or '옹벽'} {sheet.get('options', {}).get('form') or ''}"
|
||||
f" (축척 1/{int(1000 / SCALE_MM_PER_M)})",
|
||||
(0.0, top + 0.52),
|
||||
"left",
|
||||
_LABEL_SIZE,
|
||||
),
|
||||
_text(f"H = {top:g} m", (-0.28, top / 2.0), "right"),
|
||||
_text(f"상단 {w_top:.2f} m", (toe + front + w_top / 2.0, top + 0.14), "center"),
|
||||
_text(
|
||||
f"벽체 높이 {w_h:.2f} · 하단 {w_bottom:.2f} m (앞면 {front:g} · 뒷면 {back:g} 기움)",
|
||||
(right, top * 0.9),
|
||||
"left",
|
||||
),
|
||||
_text(
|
||||
f"물구멍 Ø{weep_dia} — {weep_area:g}㎡당 1개소"
|
||||
f" · 관 {weep['pipe_length_m']:g} m(관측값)",
|
||||
(right, weep_y),
|
||||
"left",
|
||||
),
|
||||
_text(
|
||||
f"기초 {f_w:.2f} × {f_t:.2f} m (앞굽 {toe:.2f} · 뒷굽 {heel:.2f})",
|
||||
(right, f_t / 2.0),
|
||||
"left",
|
||||
),
|
||||
_text(
|
||||
f"버림 T={b_t:.2f} · 폭 {f_w + 2 * over:.2f} − 키 {k_w:.2f} = {blinding_width:.2f} m",
|
||||
(right, -b_t / 2.0),
|
||||
"left",
|
||||
),
|
||||
_text(f"전단키 {k_w:.2f} × {k_d:.2f} m", (k_left + k_w / 2.0, -k_d - 0.14), "center"),
|
||||
]
|
||||
if rubble > 0:
|
||||
shapes.append(
|
||||
_text(
|
||||
f"기초잡석 T={rubble:g} m — 산출 조건 · 폭은 버림과 같음",
|
||||
(right, -b_t - rubble / 2.0),
|
||||
"left",
|
||||
)
|
||||
)
|
||||
if trench:
|
||||
shapes.append(
|
||||
_text(
|
||||
f"터파기 밑폭 {f_w + 2 * clearance:.2f} · 윗폭 {f_w + 2 * (clearance + side):.2f}"
|
||||
f" × 깊이 {f_t - dig_bottom:.2f} m (기초 {f_w:g} + 여유 {clearance:g}×2"
|
||||
f" · 옆면 1:{trench.get('side_slope', 0.0):g})",
|
||||
(right, f_t + 0.25),
|
||||
"left",
|
||||
)
|
||||
)
|
||||
below = -max(k_d, b_t + rubble) - 0.36
|
||||
notes = [
|
||||
f"단면 — 관측 원단위 {entry.get('source_note') or ''}의 수량이 나온 그 단면",
|
||||
earthwork_missing_note(entry),
|
||||
]
|
||||
for index, note in enumerate(note for note in notes if note):
|
||||
shapes.append(_text(note, (0.0, below - index * 0.16), "left"))
|
||||
return shapes
|
||||
|
||||
|
||||
def attach_figures(payload: dict[str, Any], rubble_thickness_m: float | None = None) -> None:
|
||||
"""장마다 `figure`(모양 목록 또는 `None`)와 `figure_reason` 을 실음 — 화면은 둘 중 하나."""
|
||||
for sheet in payload.get("sheets") or []:
|
||||
sheet["figure_reason"] = figure_reason(sheet)
|
||||
sheet["figure"] = build_figure(sheet, rubble_thickness_m)
|
||||
@@ -0,0 +1,321 @@
|
||||
"""구조물도 상단 그림 — **골막이 · 떼흙막이 · 바닥막이** (정본 개소당·㎡당 구조물, PLAN 12장).
|
||||
|
||||
⚠ 치수는 **표가 쓴 그 한 벌**에서 읽음 — 골막이 `erosion_check_section` ·
|
||||
떼흙막이 `SOIL_GUARD_SOD.dims` · 바닥막이 `BED_SILL_FORMS` + 산출 조건 기초잡석 두께.
|
||||
⚠ 사유 글자도 표 사유와 같은 상수(`BED_SILL_TRENCH_NOTE` 등).
|
||||
⚠ 좌표는 m · 위가 +y · 모양 목록 규약은 `B08_Quantity_Engine_StructureFigure` 와 같음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureFigure import (
|
||||
_LABEL_SIZE,
|
||||
SCALE_MM_PER_M,
|
||||
_path,
|
||||
_text,
|
||||
)
|
||||
|
||||
|
||||
def _title(sheet: dict[str, Any], y: float) -> dict[str, Any]:
|
||||
return _text(
|
||||
f"{sheet.get('title') or ''} (축척 1/{int(1000 / SCALE_MM_PER_M)})",
|
||||
(0.0, y),
|
||||
"left",
|
||||
_LABEL_SIZE,
|
||||
)
|
||||
|
||||
|
||||
def _front_with_trench(
|
||||
top: float, bottom: float, height: float, depth: float, x0: float = 0.0
|
||||
) -> list[dict[str, Any]]:
|
||||
"""정면 사다리꼴(윗변 `top` · 밑변 `bottom` 가운데 정렬) + 사면 둘·밑변을 따라 판 띠(점선)."""
|
||||
inset = (top - bottom) / 2
|
||||
corners = [(x0, height), (x0 + inset, 0.0), (x0 + inset + bottom, 0.0), (x0 + top, height)]
|
||||
shapes = [_path(corners + [corners[0]], "wall")]
|
||||
if depth > 0:
|
||||
# 사면 둘·밑변을 바깥으로 `depth` 만큼 민 선 — 이웃 변의 교점으로 잇음.
|
||||
normals = []
|
||||
for (ax, ay), (bx, by) in zip(corners[:3], corners[1:4]):
|
||||
length = math.hypot(bx - ax, by - ay)
|
||||
normals.append(((by - ay) / length, -(bx - ax) / length))
|
||||
offset = []
|
||||
for index, (px, py) in enumerate(corners):
|
||||
used = [normals[i] for i in (index - 1, index) if 0 <= i < 3]
|
||||
nx = sum(n[0] for n in used)
|
||||
ny = sum(n[1] for n in used)
|
||||
dot = used[0][0] * nx + used[0][1] * ny
|
||||
offset.append((px + nx * depth / dot, py + ny * depth / dot))
|
||||
shapes.append(_path(offset, "guide", dash=True))
|
||||
return shapes
|
||||
|
||||
|
||||
def _circle(cx: float, cy: float, r: float) -> list[tuple[float, float]]:
|
||||
return [
|
||||
(cx + r * math.cos(math.tau * i / 24), cy + r * math.sin(math.tau * i / 24))
|
||||
for i in range(25)
|
||||
]
|
||||
|
||||
|
||||
def _rect(x0: float, y0: float, x1: float, y1: float) -> list[tuple[float, float]]:
|
||||
return [(x0, y0), (x0, y1), (x1, y1), (x1, y0), (x0, y0)]
|
||||
|
||||
|
||||
def inlet_basin_figure(sheet: dict[str, Any], entry: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""집수정(□형) — 긴 벽 정면(바닥기초 · 관 · 방수로 · 터파기) + 평면(바깥·안 벽).
|
||||
|
||||
치수는 관측 줄 `section`(표 수량이 나온 그 치수). 관·방수로 **자리**는 원문 입체도 모식.
|
||||
"""
|
||||
section = entry["section"]
|
||||
inner, wall, base = section["inner"], section["wall_m"], section["base_m"]
|
||||
spill, dig = section["spillway"], section["dig"]
|
||||
length, width = inner["length_m"] + 2 * wall, inner["width_m"] + 2 * wall
|
||||
height = inner["height_m"]
|
||||
top = base + height
|
||||
radius = section["pipe_d_m"] / 2
|
||||
mid = length / 2
|
||||
notch_half_top, notch_half_bottom = spill["top_m"] / 2, spill["bottom_m"] / 2
|
||||
# 긴 벽 정면 — 윗변 가운데에 방수로 한 곳(모두 `count` 곳).
|
||||
outline = [
|
||||
(0.0, 0.0),
|
||||
(0.0, top),
|
||||
(mid - notch_half_top, top),
|
||||
(mid - notch_half_bottom, top - spill["depth_m"]),
|
||||
(mid + notch_half_bottom, top - spill["depth_m"]),
|
||||
(mid + notch_half_top, top),
|
||||
(length, top),
|
||||
(length, 0.0),
|
||||
(0.0, 0.0),
|
||||
]
|
||||
dig_len = length + dig["extra_length_m"]
|
||||
shapes = [
|
||||
_path(outline, "wall"),
|
||||
_path([(0.0, base), (length, base)], "guide"),
|
||||
_path(_circle(mid, base + radius, radius), "guide"),
|
||||
_path(
|
||||
_rect(-dig["extra_length_m"] / 2, 0.0, length + dig["extra_length_m"] / 2, top),
|
||||
"guide",
|
||||
dash=True,
|
||||
),
|
||||
]
|
||||
# 평면 — 바깥 벽 · 안 벽 · 터파기 평면(점선).
|
||||
px = length + 1.2
|
||||
shapes += [
|
||||
_path(_rect(px, 0.0, px + length, width), "wall"),
|
||||
_path(_rect(px + wall, wall, px + length - wall, width - wall), "guide"),
|
||||
_path(
|
||||
_rect(
|
||||
px - dig["extra_length_m"] / 2,
|
||||
(width - dig["width_m"]) / 2,
|
||||
px + length + dig["extra_length_m"] / 2,
|
||||
(width + dig["width_m"]) / 2,
|
||||
),
|
||||
"guide",
|
||||
dash=True,
|
||||
),
|
||||
]
|
||||
right = px + length + 0.5
|
||||
shapes += [
|
||||
_title(sheet, max(top, dig["width_m"]) + 0.55),
|
||||
_text("정면(긴 벽)", (mid, top + 0.15), "center"),
|
||||
_text("평면", (px + length / 2, width + 0.15 + (dig["width_m"] - width) / 2), "center"),
|
||||
_text(
|
||||
f"안 {inner['length_m']:g}×{inner['width_m']:g}×{height:g} · 벽 {wall:g} · 바닥기초"
|
||||
f" {length:g}×{width:g}×{base:g} m",
|
||||
(right, top * 0.9),
|
||||
"left",
|
||||
),
|
||||
_text(f"관 Ø{section['pipe_d_m']:g}", (right, top * 0.65), "left"),
|
||||
_text(
|
||||
f"방수로 {spill['count']}곳 (상장 {spill['top_m']:g} · 하장 {spill['bottom_m']:g}"
|
||||
f" · 고 {spill['depth_m']:g})",
|
||||
(right, top * 0.4),
|
||||
"left",
|
||||
),
|
||||
_text(
|
||||
f"터파기 {dig_len:g} × {dig['width_m']:g} × 깊이 {top:g} m",
|
||||
(right, top * 0.15),
|
||||
"left",
|
||||
),
|
||||
_text("관·방수로 자리는 원문 입체도 모식 — 치수는 산출식", (0.0, -0.3), "left"),
|
||||
]
|
||||
return shapes
|
||||
|
||||
|
||||
def pavement_figure(sheet: dict[str, Any], entry: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""물넘이포장 — ㎡당 한 칸 단면: 슬래브 두께(규격) · 와이어메쉬(2/3T) · 터파기(두께만큼)."""
|
||||
thickness = float(entry["spec"]["thickness_cm"]) / 100
|
||||
mesh = thickness * entry["section"]["mesh_from_bottom_ratio"]
|
||||
width = 1.0
|
||||
shapes = [
|
||||
_path(_rect(0.0, 0.0, width, thickness), "wall"),
|
||||
_path([(0.05, mesh), (width - 0.05, mesh)], "guide", dash=True),
|
||||
_path(_rect(-0.12, 0.0, width + 0.12, thickness), "guide", dash=True),
|
||||
]
|
||||
right = width + 0.4
|
||||
shapes += [
|
||||
_title(sheet, thickness + 0.45),
|
||||
_text("폭 1 m 당(표는 ㎡당)", (width / 2, thickness + 0.12), "center"),
|
||||
_text(f"콘크리트 T={thickness:g} m", (right, thickness * 0.8), "left"),
|
||||
_text(f"와이어메쉬 — 밑에서 {mesh:.3f} m (2/3T)", (right, mesh - 0.02), "left"),
|
||||
_text(f"터파기 깊이 {thickness:g} m (= 두께)", (right, 0.0), "left"),
|
||||
]
|
||||
for index, note in enumerate(entry.get("notes") or []):
|
||||
shapes.append(_text(str(note), (0.0, -0.25 - index * 0.14), "left"))
|
||||
return shapes
|
||||
|
||||
|
||||
def erosion_check_figure(sheet: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""골막이 — 정면(사다리꼴 · 방수로 관 · 바닥파기 띠) + 옆 단면(상부·하부 두께)."""
|
||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import erosion_check_section
|
||||
|
||||
geo = erosion_check_section(float(sheet.get("height_m") or 0.0), sheet.get("options") or {})
|
||||
top, bottom, height = geo["top_m"], geo["bottom_m"], geo["height_m"]
|
||||
shapes = _front_with_trench(top, bottom, height, geo["trench_depth"])
|
||||
if geo["spillway"]:
|
||||
shapes.append(_path(_circle(top / 2, geo["pipe_r"], geo["pipe_r"]), "guide"))
|
||||
# 옆 단면 — 뒷면 수직 · 앞면이 하부 두께에서 상부 두께로 좁아짐(표의 두께식 그대로).
|
||||
x0 = top + 1.2
|
||||
shapes.append(
|
||||
_path(
|
||||
[
|
||||
(x0, 0.0),
|
||||
(x0, height),
|
||||
(x0 + geo["top_t"], height),
|
||||
(x0 + geo["bottom_t"], 0.0),
|
||||
(x0, 0.0),
|
||||
],
|
||||
"wall",
|
||||
)
|
||||
)
|
||||
right = x0 + geo["bottom_t"] + 0.3
|
||||
shapes += [
|
||||
_title(sheet, height + 0.55),
|
||||
_text(f"상장 ⓐ {top:g} m", (top / 2, height + 0.14), "center"),
|
||||
_text(f"하장 ⓑ {bottom:g} m", (top / 2, -geo["trench_depth"] - 0.18), "center"),
|
||||
_text(f"H = {height:g} m", (-0.3 - geo["trench_depth"], height / 2), "right"),
|
||||
_text(
|
||||
f"상부 {geo['top_t']:.2f} · 하부 {geo['bottom_t']:.2f} · 평균 {geo['thickness']:.2f} m"
|
||||
f" (뒷길이 {geo['back_cm']}㎝ + 0.1H / 0.4H)",
|
||||
(right, height * 0.85),
|
||||
"left",
|
||||
),
|
||||
_text(f"반수면 1:{geo['slope']:g}", (right, height * 0.62), "left"),
|
||||
_text(
|
||||
f"바닥파기 폭 {geo['trench_width']:.2f} × 깊이 {geo['trench_depth']:g} m"
|
||||
f" (사면장 {geo['slant']:g} × 2 + 하장)",
|
||||
(right, height * 0.39),
|
||||
"left",
|
||||
),
|
||||
]
|
||||
if geo["spillway"]:
|
||||
shapes.append(
|
||||
_text(
|
||||
f"방수로 파형강관 Ø{geo['pipe_r'] * 2:g} — 정면적에서 뺌",
|
||||
(right, height * 0.16),
|
||||
"left",
|
||||
)
|
||||
)
|
||||
return shapes
|
||||
|
||||
|
||||
def soil_guard_figure(sheet: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""떼흙막이 — 정본 평균 붙박이 치수의 정면(떼 두께 · 바닥파기 띠)."""
|
||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import SOIL_GUARD_SOD
|
||||
|
||||
d = SOIL_GUARD_SOD["dims"]
|
||||
top, bottom, height = d["top_m"], d["bottom_m"], d["height_m"]
|
||||
shapes = _front_with_trench(top, bottom, height, d["trench_depth_m"])
|
||||
# 머리떼 — 윗변 위 떼 한 켜(두께 = 떼 폭 0.2 로 보임).
|
||||
shapes.append(
|
||||
_path(
|
||||
[(0.0, height), (0.0, height + d["sod_m"]), (top, height + d["sod_m"]), (top, height)],
|
||||
"guide",
|
||||
)
|
||||
)
|
||||
right = top + 0.4
|
||||
shapes += [
|
||||
_title(sheet, height + d["sod_m"] + 0.45),
|
||||
_text(f"상단 {top:g} m", (top / 2, height + d["sod_m"] + 0.12), "center"),
|
||||
_text(f"하단 {bottom:g} m", (top / 2, -d["trench_depth_m"] - 0.15), "center"),
|
||||
_text(f"H = {height:g} m", (-0.3, height / 2), "right"),
|
||||
_text(
|
||||
f"떼 20×20㎝ · 폭 {d['sod_m']:g} · 기슭(사면장) {d['slant_m']:g} m",
|
||||
(right, height * 0.9),
|
||||
"left",
|
||||
),
|
||||
_text(
|
||||
f"바닥파기 폭 {d['trench_width_m']:g} × 깊이 {d['trench_depth_m']:g} m"
|
||||
" (기슭 × 2 + 하단)",
|
||||
(right, height * 0.45),
|
||||
"left",
|
||||
),
|
||||
_text("치수 — 정본 「떼흙막이」 평균 붙박이(개소 제원을 안 봄)", (right, 0.0), "left"),
|
||||
]
|
||||
return shapes
|
||||
|
||||
|
||||
def bed_sill_figure(
|
||||
sheet: dict[str, Any], rubble_thickness_m: float | None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""바닥막이(돌붙임) — ㎡당 한 칸의 단면: 돌 켜 · (찰) 버림 · 기초잡석 · 터파기(돌 두께만)."""
|
||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import RUBBLE_BASE_THICKNESS_M
|
||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import (
|
||||
BED_SILL_FORMS,
|
||||
BED_SILL_TRENCH_NOTE,
|
||||
)
|
||||
|
||||
form = str((sheet.get("options") or {}).get("form") or "").strip()
|
||||
table = BED_SILL_FORMS[form]
|
||||
back = table["back_len_m"]
|
||||
blinding = table["blinding_m3_per_m2"] or 0.0 # ㎥/㎡ = 두께(m)
|
||||
rubble = RUBBLE_BASE_THICKNESS_M if rubble_thickness_m is None else float(rubble_thickness_m)
|
||||
rubble = rubble if blinding > 0 else 0.0 # 표도 버림이 선 곳에만 기초잡석을 세움
|
||||
width = 1.0
|
||||
shapes = [_path([(0.0, 0.0), (0.0, back), (width, back), (width, 0.0), (0.0, 0.0)], "wall")]
|
||||
if blinding > 0:
|
||||
shapes.append(
|
||||
_path([(0.0, 0.0), (0.0, -blinding), (width, -blinding), (width, 0.0)], "guide")
|
||||
)
|
||||
if rubble > 0:
|
||||
shapes.append(
|
||||
_path(
|
||||
[
|
||||
(0.0, -blinding),
|
||||
(0.0, -blinding - rubble),
|
||||
(width, -blinding - rubble),
|
||||
(width, -blinding),
|
||||
],
|
||||
"guide",
|
||||
dash=True,
|
||||
)
|
||||
)
|
||||
# 터파기 — 표 식 그대로 면적 × 돌 두께(윗면에서 돌 켜 밑까지).
|
||||
shapes.append(
|
||||
_path(
|
||||
[(-0.15, back), (-0.15, 0.0), (width + 0.15, 0.0), (width + 0.15, back)],
|
||||
"guide",
|
||||
dash=True,
|
||||
)
|
||||
)
|
||||
right = width + 0.4
|
||||
shapes += [
|
||||
_title(sheet, back + 0.4),
|
||||
_text(
|
||||
f"{form} — 두께(뒷길이) {back:g} m · {table['stone_spec']}",
|
||||
(right, back * 0.75),
|
||||
"left",
|
||||
),
|
||||
_text("폭 1 m 당(표는 ㎡당)", (width / 2, back + 0.12), "center"),
|
||||
_text(f"터파기 깊이 {back:g} m (면적 × 두께)", (right, back * 0.3), "left"),
|
||||
]
|
||||
if blinding > 0:
|
||||
shapes.append(_text(f"버림 T={blinding:g} m", (right, -blinding / 2), "left"))
|
||||
if rubble > 0:
|
||||
shapes.append(
|
||||
_text(f"기초잡석 T={rubble:g} m — 산출 조건", (right, -blinding - rubble / 2), "left")
|
||||
)
|
||||
shapes.append(_text(BED_SILL_TRENCH_NOTE, (0.0, -blinding - rubble - 0.25), "left"))
|
||||
return shapes
|
||||
@@ -19,13 +19,36 @@ from typing import Any
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import TEMPLATE_DIR
|
||||
from config.config_system import STORAGE_BASE_DIR
|
||||
|
||||
#: 단 이름 — 고르개에 이 차례로 보임(가까운 것부터).
|
||||
TIERS = ("personal", "company", "program")
|
||||
#: 단 이름 — 고르개에 이 차례로 보임(가까운 것부터). `received` = 동료가 보낸 것(공유 · 개인 단을 안 덮음).
|
||||
TIERS = ("personal", "received", "company", "program")
|
||||
RECEIVED_SUBDIR = "library_received"
|
||||
#: 항목 코드 — 파일 이름이 되므로 모양을 먼저 봄(경로 벗어남 막이).
|
||||
CODE_PATTERN = re.compile(r"^AX-ST-[0-9a-f]{8}$")
|
||||
LIBRARY_SUBDIR = "library"
|
||||
|
||||
|
||||
#: 프로그램 기본 작업본 자리 — 회사 번호 폴더와 안 겹치게 밑줄 이름.
|
||||
PROGRAM_SUBDIR = "_program"
|
||||
#: 프로그램 기본 발행본에서 뺄 출처 칸 — 어느 공사인지(2026-09-14 브레인 판정 ②).
|
||||
ORIGIN_MASKED_KEYS = ("project", "file")
|
||||
ORIGIN_MASKED_NOTE = "원문에서 뽑음 — 공사명은 발행 시 가림"
|
||||
|
||||
|
||||
def program_library_dir() -> Path:
|
||||
"""프로그램 기본 **작업본**(시스템 관리자가 화면에서 고친 것) — storage(회사·개인 단과 같은 결).
|
||||
|
||||
git `resources/library_structure`(`TEMPLATE_DIR`)는 **씨앗** — 쓰기 금지(데이터 3층의 초기값 자리).
|
||||
"""
|
||||
return Path(STORAGE_BASE_DIR).resolve() / PROGRAM_SUBDIR / LIBRARY_SUBDIR
|
||||
|
||||
|
||||
def program_items() -> list[dict[str, Any]]:
|
||||
"""프로그램 기본 = 작업본 + 씨앗 중 **작업본에 없는 코드만**(관리자가 고친 것이 배포에 안 덮임)."""
|
||||
stored = _items(program_library_dir())
|
||||
codes = {item.get("code") for item in stored}
|
||||
return [*stored, *(item for item in _items(TEMPLATE_DIR) if item.get("code") not in codes)]
|
||||
|
||||
|
||||
def tier_dirs(company_id: Any, user_id: Any) -> dict[str, Path]:
|
||||
"""로그인한 사람의 3단 폴더. 회사가 없으면(시스템 관리자) 개인·회사 단은 없음."""
|
||||
dirs: dict[str, Path] = {}
|
||||
@@ -33,8 +56,9 @@ def tier_dirs(company_id: Any, user_id: Any) -> dict[str, Path]:
|
||||
company = Path(STORAGE_BASE_DIR).resolve() / str(company_id)
|
||||
if user_id is not None:
|
||||
dirs["personal"] = company / str(user_id) / LIBRARY_SUBDIR
|
||||
dirs["received"] = company / str(user_id) / RECEIVED_SUBDIR
|
||||
dirs["company"] = company / LIBRARY_SUBDIR
|
||||
dirs["program"] = TEMPLATE_DIR
|
||||
dirs["program"] = program_library_dir()
|
||||
return dirs
|
||||
|
||||
|
||||
@@ -44,22 +68,44 @@ 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 _tier_items(dirs: dict[str, Path], tier: str) -> list[dict[str, Any]]:
|
||||
"""단의 항목 — 프로그램 기본은 작업본 + 씨앗, 나머지는 그 폴더."""
|
||||
if tier == "program":
|
||||
return program_items()
|
||||
return _items(dirs[tier]) if tier in dirs else []
|
||||
|
||||
|
||||
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])
|
||||
for item in _tier_items(dirs, tier)
|
||||
if item.get("type_id") == type_id and item.get("code")
|
||||
]
|
||||
|
||||
|
||||
def find_item(dirs: dict[str, Path], tier: str, code: str) -> dict[str, Any] | None:
|
||||
folder = dirs.get(tier)
|
||||
if folder is None:
|
||||
if tier not in dirs:
|
||||
return None
|
||||
return next((item for item in _items(folder) if item.get("code") == code), None)
|
||||
return next((item for item in _tier_items(dirs, tier) if item.get("code") == code), None)
|
||||
|
||||
|
||||
def project_library_dir(project_root: str | Path) -> Path:
|
||||
@@ -82,7 +128,7 @@ def available_templates(project_root: str | Path | None) -> list[dict[str, Any]]
|
||||
|
||||
⛔ 개인·회사 단은 안 넣음 — 표를 그릴 때 라이브러리를 매번 읽지 않음(판정 Ⓑ).
|
||||
"""
|
||||
return [*_items(TEMPLATE_DIR), *project_templates(project_root).values()]
|
||||
return [*program_items(), *project_templates(project_root).values()]
|
||||
|
||||
|
||||
def import_item(project_root: str | Path, item: dict[str, Any], tier: str) -> None:
|
||||
@@ -111,17 +157,44 @@ def _write(folder: Path, item: dict[str, Any]) -> None:
|
||||
(folder / f"{item['code']}.json").write_text(text, encoding="utf-8")
|
||||
|
||||
|
||||
def save_personal(folder: Path, template: dict[str, Any], overrides: dict[str, Any] | None) -> str:
|
||||
"""[내 라이브러리에 저장] — 양식 + 프로젝트에서 고친 식을 **개인 단에 한 벌**로 씀. 코드.
|
||||
def _personal_code(folder: Path, type_id: Any, existing: list[dict[str, Any]] | None = None) -> str:
|
||||
"""그 단 코드 — 같은 종류가 있으면 그 코드(덮어씀 · 판정 Ⓐ 종류당 하나), 없으면 새로.
|
||||
|
||||
⚠ 반대 방향(작업본 → 개인 단)이라 프로젝트는 안 바꿈(브레인 판정 Ⓑ).
|
||||
⚠ 개인 단에 같은 종류가 있으면 **그 코드로 덮어씀** — 지금은 종류당 하나(판정 Ⓐ).
|
||||
프로그램 기본은 씨앗까지 봄(`existing`) — 씨앗 코드로 덮어써야 작업본이 씨앗을 이김.
|
||||
"""
|
||||
items = _items(folder) if existing is None else existing
|
||||
same = [item for item in items 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],
|
||||
overrides: dict[str, Any] | None,
|
||||
unit_price_rows: list[dict[str, Any]] | None = None,
|
||||
tier: str = "personal",
|
||||
name: str | None = None,
|
||||
) -> str:
|
||||
"""[내 라이브러리에 저장]·발행 — 양식 + 고친 식·줄 조합을 **그 단에 한 벌**로 씀. 코드.
|
||||
|
||||
⚠ `name` — 이름표(화면이 「종류 + 제원 요약」을 제안 · 사용자가 고침 · 10-A ⑫). 비면 양식 이름.
|
||||
|
||||
⚠ 반대 방향(작업본 → 라이브러리)이라 프로젝트는 안 바꿈(브레인 판정 Ⓑ).
|
||||
⚠ 같은 종류가 있으면 **그 코드로 덮어씀** — 지금은 종류당 하나(판정 Ⓐ).
|
||||
⚠ `tier` — 회사(MASTER)·프로그램 기본(SYSTEM_ADMIN) 발행도 같은 모양(2026-09-14 브레인 승인).
|
||||
⛔ 수동 단가는 안 실음 — 프로젝트의 값이라 양식에 실으면 남의 프로젝트로 감(브레인 판정).
|
||||
"""
|
||||
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)}"
|
||||
existing = program_items() if tier == "program" else None
|
||||
code = _personal_code(folder, template.get("type_id"), existing)
|
||||
# 고친 식·반올림이 곧 이 항목의 값 — 「사용자」 표시와 되돌릴 자리는 떼어 냄.
|
||||
dropped = {"default_formula", "default_rounding"}
|
||||
rows = [
|
||||
@@ -129,7 +202,15 @@ def save_personal(folder: Path, template: dict[str, Any], overrides: dict[str, A
|
||||
for row in overridden_rows(template, overrides)
|
||||
]
|
||||
item = {k: v for k, v in template.items() if k != "imported_from"}
|
||||
_write(folder, {**item, "code": code, "library_tier": "personal", "rows": rows})
|
||||
if name and name.strip():
|
||||
item["name"] = name.strip()
|
||||
if tier == "program" and isinstance(item.get("origin"), dict):
|
||||
# 모든 회사로 가는 발행본 — 원문 공사명·파일명은 안 실음(원본·회사 단은 그대로 · 브레인 ②③).
|
||||
kept = {k: v for k, v in item["origin"].items() if k not in ORIGIN_MASKED_KEYS}
|
||||
item["origin"] = {**kept, "masked": ORIGIN_MASKED_NOTE}
|
||||
if unit_price_rows is not None:
|
||||
item["unit_price"] = {**(item.get("unit_price") or {}), "rows": unit_price_rows}
|
||||
_write(folder, {**item, "code": code, "library_tier": tier, "rows": rows})
|
||||
return code
|
||||
|
||||
|
||||
@@ -151,8 +232,18 @@ def pin_program_templates(project_root: str | Path) -> int:
|
||||
"""
|
||||
pinned = project_templates(project_root)
|
||||
count = 0
|
||||
for item in _items(TEMPLATE_DIR):
|
||||
for item in program_items():
|
||||
if item.get("type_id") and item.get("code") and item["type_id"] not in pinned:
|
||||
import_item(project_root, item, "program")
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def share_item(item: dict[str, Any], to_folder: Path, sender: dict[str, Any]) -> str:
|
||||
"""[동료에게 보내기] — 내 개인 단 항목을 동료의 **받음 단**에 복사(개인 단을 안 덮음 · 브레인 판정 ①).
|
||||
|
||||
코드는 그대로(다시 보내면 같은 자리를 새로 씀) · 출처 공사명도 그대로(같은 회사 · 판정 ③).
|
||||
"""
|
||||
body = {k: v for k, v in item.items() if k != "imported_from"}
|
||||
_write(to_folder, {**body, "library_tier": "received", "received_from": sender})
|
||||
return str(body["code"])
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"""구조물도 일위대가 → B09 내역 **받는 문** (PLAN 6장 첫 일감 ② · 2026-09-13 브레인 판정).
|
||||
|
||||
⭐ 종전엔 구조물도가 세운 일위대가(`B-AX-ST-*`)가 인계로 **한 번도 안 나갔음** — 돌쌓기는
|
||||
`FP-13-04-05` ㎡ 단가만 가고 모르터·기초잡석은 따로거나 빠졌음. 사슬의 진짜 빈칸.
|
||||
|
||||
한 벌로 두는 것 — 인계(B08)와 내역(B09)이 **같은 목록·같은 키**를 봄
|
||||
양식 일위대가로 셀 장 → `priced_sheets` (장마다 코드 · 제원 키 · 구성원 · 품은 줄)
|
||||
인계 줄 → `template_row` (코드 AX-ST · 갈래 = 제원 키 · 수량 = 연장 m)
|
||||
내역 금액 → `price_sheets` (B09 단가표로 B08 일위대가 엔진을 돌림 — 코드 한 벌)
|
||||
|
||||
⚠ 호표 코드 `B-AX-ST-<8hex>#<키>` — 키는 **사람이 읽는 제목이 아니라** 양식 제원 칸 값을 칸 이름
|
||||
차례로 이은 것(연장 L 뺌) + `normalize_variant_key`. 제목 글자가 바뀌어도 코드는 안 바뀜.
|
||||
⚠ 양식 일위대가가 품은 원단위 줄(돌쌓기·모르터·기초잡석)은 **따로 인계하지 않음** — `covered`.
|
||||
⚠ 금액 정본은 B09 — 여기 `price_sheets` 는 B09 가 넘긴 단가표로만 셈. 수동 단가도 입력으로 씀.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterable
|
||||
from typing import Any
|
||||
|
||||
#: 인계 줄 갈래 축 이름 — 「구조물도 장 제원」.
|
||||
VARIANT_AXIS = "structure_sheet"
|
||||
|
||||
|
||||
def _key_value(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||
return format(float(value), ".6g")
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def sheet_variant_key(template: dict[str, Any], sheet: dict[str, Any]) -> str:
|
||||
"""장의 **제원 키** — 양식 칸 이름 차례로 `이름=값` 을 `|` 로 이음(연장 칸 뺌: m당)."""
|
||||
values = (sheet.get("formula_sheet") or {}).get("vars") or {}
|
||||
specs = template.get("vars") or {}
|
||||
return "|".join(
|
||||
f"{name}={_key_value(values.get(name))}"
|
||||
for name in sorted(specs)
|
||||
if specs[name].get("source") != "length_m"
|
||||
)
|
||||
|
||||
|
||||
def structure_price_code(code: str, key: str) -> str:
|
||||
"""호표 코드 `B-AX-ST-<8hex>#<정규화 키>` — 인계·내역이 이것 하나로 만남."""
|
||||
from B09_Estimation.B09_Estimation_UnitPrice import normalize_variant_key
|
||||
|
||||
return f"B-{code}#{normalize_variant_key(key)}"
|
||||
|
||||
|
||||
def priced_sheets(
|
||||
sheets: Iterable[dict[str, Any]],
|
||||
templates: dict[str, dict[str, Any]],
|
||||
row_edits: dict[str, Any] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""양식 일위대가로 셀 장 — 양식(고친 줄 조합 얹음)에 일위대가 줄과 코드가 있는 장만."""
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import with_rows
|
||||
|
||||
found = []
|
||||
for sheet in sheets:
|
||||
type_id = str(sheet.get("type_id") or "")
|
||||
base = templates.get(type_id)
|
||||
if not base or not base.get("code") or not sheet.get("formula_sheet"):
|
||||
continue
|
||||
template = with_rows(base, (row_edits or {}).get(type_id))
|
||||
rows = template["unit_price"]["rows"]
|
||||
if not rows:
|
||||
continue
|
||||
names = {row.get("seq"): str(row.get("name") or "") for row in template.get("rows") or []}
|
||||
found.append(
|
||||
{
|
||||
"type_id": type_id,
|
||||
"code": str(template["code"]),
|
||||
"name": str(template.get("name") or type_id),
|
||||
"title": str(sheet.get("title") or ""),
|
||||
"key": sheet_variant_key(template, sheet),
|
||||
"structure_ids": [
|
||||
str(member["structure_id"])
|
||||
for member in sheet.get("members") or []
|
||||
if member.get("structure_id")
|
||||
],
|
||||
# 일위대가가 품은 원단위 줄 이름 — 따로 인계하면 두 번 셈.
|
||||
"covered": sorted(
|
||||
{names[r["from_row"]] for r in rows if r.get("from_row") in names}
|
||||
),
|
||||
"template": template,
|
||||
"sheet": sheet,
|
||||
}
|
||||
)
|
||||
return found
|
||||
|
||||
|
||||
def by_structure(entries: Iterable[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
||||
return {sid: entry for entry in entries for sid in entry["structure_ids"]}
|
||||
|
||||
|
||||
def template_row(row: dict[str, Any], structure: dict[str, Any], entry: dict[str, Any]) -> dict:
|
||||
"""전개 인계 줄(같은 칸 29개 계약)을 **양식 일위대가 줄**로 바꿔 씀 — 코드·갈래·수량·단위만."""
|
||||
length = float(structure.get("length_m") or 0.0)
|
||||
in_bill = length > 0
|
||||
return {
|
||||
**row,
|
||||
"work_item_code": entry["code"],
|
||||
"name": entry["name"],
|
||||
"spec": entry["title"],
|
||||
"spec_detail": entry["title"],
|
||||
"unit": "m",
|
||||
"quantity": length,
|
||||
"composite_parts": None,
|
||||
"composite_not_ready": None,
|
||||
"structure_kind": None,
|
||||
"variant_axis": VARIANT_AXIS,
|
||||
"variant_value": entry["key"],
|
||||
"secondary_axes": None,
|
||||
"spec_class": None,
|
||||
"spec_class_basis": f"구조물도 양식 일위대가 — 품은 줄: {'·'.join(entry['covered'])}",
|
||||
# 막힘은 일위대가 표가 정함(B09 가 셈) — 전개 쪽 갈래 사유는 여기 안 실음.
|
||||
"blocked_kind": None if in_bill else row.get("blocked_kind"),
|
||||
"blocked_reason": "" if in_bill else row.get("blocked_reason") or "연장이 0 이라 안 세움",
|
||||
"in_bill": in_bill,
|
||||
"in_bill_reason": "" if in_bill else "연장이 0 이라 내역에 안 세움",
|
||||
}
|
||||
|
||||
|
||||
def price_sheets(
|
||||
entries: Iterable[dict[str, Any]],
|
||||
book: Any,
|
||||
find_variant: Callable[[str, str], str | None],
|
||||
library: Iterable[dict[str, Any]],
|
||||
manual_all: dict[str, Any] | None = None,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""B09 내역이 쓸 호표 금액 `{B-AX-ST-…#키: {money, total, blocked, unconfirmed, reasons}}`.
|
||||
|
||||
⚠ `money` 는 **호표 표의 성분 소계**(칸마다 0.1원 절사해 더한 뒤 성분마다 1원 절사) ·
|
||||
`total` 은 그 합 — 명세 7장 「호표 안 성분 소계는 절사」. 안 자른 값을 쓰면 구조물도
|
||||
화면(192,429)과 내역 단가(192,430)가 1원 갈림(2026-09-13 검증 프로젝트 실측).
|
||||
"""
|
||||
from decimal import Decimal
|
||||
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import unit_price_money
|
||||
from B09_Estimation.B09_Estimation_PriceBook import Money3
|
||||
|
||||
prices: dict[str, dict[str, Any]] = {}
|
||||
library = list(library)
|
||||
for entry in entries:
|
||||
assembled = unit_price_money(
|
||||
entry["template"],
|
||||
entry["sheet"],
|
||||
book,
|
||||
find_variant,
|
||||
library,
|
||||
(manual_all or {}).get(entry["type_id"]) or {},
|
||||
)
|
||||
if assembled is None:
|
||||
continue
|
||||
table, _exact = assembled
|
||||
prices[structure_price_code(entry["code"], entry["key"])] = {
|
||||
"money": Money3(*(Decimal(str(table[k])) for k in ("material", "labor", "expense"))),
|
||||
"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"]
|
||||
if row["reason"] and not row["skipped"] and "total" not in row
|
||||
],
|
||||
}
|
||||
return prices
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
"""B08 **구조물 집계표** — 측점별 구조물 한 줄 · 종류별 표 (PLAN 2장 · 사용자 확정 ④).
|
||||
|
||||
한 줄 = 측점 + 종류 + 실치수(제원 칸) + 개소·연장. 실무 「돌-골막이치수」 치수조서가
|
||||
실치수를 들고 구조도 탭이 끌어 쓰는 모양을 **한 탭으로 겸함**.
|
||||
|
||||
값이 어디서 오나 — 칸마다 출처를 붙임
|
||||
auto 정본에 적힌 값 — `structures.json`(B05) · `pipe_points.json`(계곡 통과 시설) ·
|
||||
관 연장은 B06 횡단 `design.pipe_length_m`
|
||||
user 이 표에서 사람이 고쳐 **정본에 적은** 값 — 산출 조건에 「손댄 칸」 표만 둠(브레인 판정)
|
||||
library 정본 칸이 비어 **양식 기본값**으로 선 칸(구조물도 양식 `vars.*.default`)
|
||||
empty 정본도 양식도 값이 없음 — 계산 쪽이 막히거나 기준값으로 돎
|
||||
⚠ 사용자 손 값은 덮개층이 아니라 **정본에 씀** — 도면·수량이 한 값(지침 5장 「쪼개지 않음」).
|
||||
⚠ 손댄 칸의 정본 값이 뒤에 B05 에서 바뀌면 그 칸은 자동으로 돌아가되 **조용히 말고** 줄에 알림.
|
||||
⚠ 값을 여기서 짓지 않음 — 읽어 줄 세우기만. 연장이 없는 관은 빈칸(0 으로 안 채움).
|
||||
⚠ 관 지점 정본 타입(`managed_by`)은 `structures.json` 에 있어도 안 셈 — `pipe_points.json` 이 주인.
|
||||
⛔ 고칠 수 있는 칸은 **상세 칸(`phase: detail`)만** — 자리·길이·높이·관경 같은 놓기 칸(`b05`)은
|
||||
시·종점·횡단 설계와 **한 벌로 움직여**(B05 에서 길이 = 전 + 후, 시·종점이 따라 섬) 여기서 한 칸만
|
||||
고치면 쪼개짐(지침 5장). 그 칸은 구조물 놓기(B05)에서 고침.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
SOURCE_AUTO = "auto"
|
||||
SOURCE_USER = "user"
|
||||
SOURCE_LIBRARY = "library"
|
||||
SOURCE_EMPTY = "empty"
|
||||
#: 산출 조건 자리 — 손댄 칸 `{줄 id: {칸: {"value": 적은 값, "was": 고치기 전 정본 값}}}`.
|
||||
USER_CELLS_KEY = "structure_summary_user_cells"
|
||||
|
||||
#: 계곡 통과 시설 `facility` → 레지스트리 종류. 빈 값은 배관(`PipePoint` 기본).
|
||||
FACILITY_TYPES = {
|
||||
"pipe": "pipe",
|
||||
"box_culvert": "box_culvert",
|
||||
"ford_pavement": "ford_pavement",
|
||||
"ford_bridge": "ford_bridge",
|
||||
"revetment": "revetment",
|
||||
}
|
||||
|
||||
|
||||
def _blank(value: Any) -> bool:
|
||||
return value is None or value == ""
|
||||
|
||||
|
||||
def _number(value: Any) -> float | None:
|
||||
if isinstance(value, bool) or _blank(value):
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _defaults(template: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""양식 제원 칸 기본값 `{옵션 키: 기본값}` — 정본 칸이 비었을 때 계산이 쓰는 값."""
|
||||
return {
|
||||
str(spec["option"]): spec["default"]
|
||||
for spec in ((template or {}).get("vars") or {}).values()
|
||||
if spec.get("option") and "default" in spec and not _blank(spec["default"])
|
||||
}
|
||||
|
||||
|
||||
def _same(left: Any, right: Any) -> bool:
|
||||
"""정본 값과 적은 값이 같은가 — 숫자는 3.0 과 "3" 을 같게 봄."""
|
||||
a, b = _number(left), _number(right)
|
||||
return a == b if a is not None and b is not None else left == right
|
||||
|
||||
|
||||
def _cells(
|
||||
keys: Iterable[str],
|
||||
options: dict[str, Any],
|
||||
defaults: dict[str, Any],
|
||||
marks: dict[str, Any],
|
||||
) -> dict:
|
||||
cells = {}
|
||||
for key in keys:
|
||||
current = None if _blank(options.get(key)) else options[key]
|
||||
mark = marks.get(key)
|
||||
if mark is not None and not _same(current, mark.get("value")):
|
||||
# 사람이 적은 값을 B05 가 바꿈 — 자동으로 돌아가되 옛 값을 함께 실어 화면이 알림.
|
||||
cells[key] = {
|
||||
"value": current,
|
||||
"source": SOURCE_EMPTY if current is None else SOURCE_AUTO,
|
||||
"replaced_user_value": mark.get("value"),
|
||||
}
|
||||
elif mark is not None:
|
||||
cells[key] = {"value": current, "source": SOURCE_USER, "was": mark.get("was")}
|
||||
elif current is not None:
|
||||
cells[key] = {"value": options[key], "source": SOURCE_AUTO}
|
||||
elif key in defaults:
|
||||
cells[key] = {"value": defaults[key], "source": SOURCE_LIBRARY}
|
||||
else:
|
||||
cells[key] = {"value": None, "source": SOURCE_EMPTY}
|
||||
return cells
|
||||
|
||||
|
||||
def build_summary(
|
||||
structures: Iterable[dict[str, Any]],
|
||||
pipe_points: Iterable[dict[str, Any]],
|
||||
types: dict[str, Any],
|
||||
templates: dict[str, dict[str, Any]] | None = None,
|
||||
pipe_lengths: dict[float, float] | None = None,
|
||||
user_cells: dict[str, dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""종류별 표 목록(레지스트리 차례) — 표마다 칸 정의·줄·개소·연장 합·평균치수.
|
||||
|
||||
`types` 는 `structure_type_map()` · `templates` 는 종류별 양식(프로젝트에 박힌 것 → 기본) ·
|
||||
`pipe_lengths` 는 B06 횡단의 `{측점: 관 연장}`(`pipe_lengths_from_designs`) ·
|
||||
`user_cells` 는 이 표에서 손댄 칸(`USER_CELLS_KEY` 모양).
|
||||
"""
|
||||
# 관 길이 찾기는 배수관 물량과 **같은 규칙**(허용 0.5m) — 두 표가 같은 관에 다른 연장을 안 적게.
|
||||
from B08_Quantity.B08_Quantity_Engine_Pipe import _nearest
|
||||
|
||||
templates = templates or {}
|
||||
lengths = pipe_lengths or {}
|
||||
rows_by_type: dict[str, list[dict[str, Any]]] = {}
|
||||
notes: list[str] = []
|
||||
|
||||
def add(type_id: str, row: dict[str, Any]) -> None:
|
||||
definition = types.get(type_id)
|
||||
if definition is None:
|
||||
notes.append(f"{type_id}: 레지스트리에 없는 종류라 안 셈")
|
||||
return
|
||||
options = row.pop("options")
|
||||
keys = [field.key for field in definition.options]
|
||||
marks = (user_cells or {}).get(str(row["id"])) or {}
|
||||
row["cells"] = _cells(keys, options, _defaults(templates.get(type_id)), marks)
|
||||
replaced = [key for key, cell in row["cells"].items() if "replaced_user_value" in cell]
|
||||
if replaced:
|
||||
labels = {field.key: field.label for field in definition.options}
|
||||
row["replaced"] = [labels[key] for key in replaced]
|
||||
if definition.design_owner:
|
||||
row["note"] = f"{definition.design_owner}가 수량을 셈 — 여기선 자리만"
|
||||
elif definition.reference_only:
|
||||
row["note"] = "전문 상세설계 대상 — 배치까지만"
|
||||
rows_by_type.setdefault(type_id, []).append(row)
|
||||
|
||||
for item in structures:
|
||||
type_id = str(item.get("type_id") or "")
|
||||
definition = types.get(type_id)
|
||||
if definition is not None and definition.managed_by:
|
||||
continue # 관 지점 정본이 주인 — 옛 저장분이 남아 있어도 두 번 안 셈
|
||||
options = dict(item.get("options") or {})
|
||||
start, end = item.get("start_m"), item.get("end_m")
|
||||
span = abs(float(end) - float(start)) if start is not None and end is not None else None
|
||||
stated = _number(options.get("length_m"))
|
||||
add(
|
||||
type_id,
|
||||
{
|
||||
"id": item.get("structure_id"),
|
||||
"origin": "structures",
|
||||
"chainage_m": item.get("chainage_m"),
|
||||
"start_m": start,
|
||||
"end_m": end,
|
||||
"count": 1,
|
||||
# 원단위 전개와 같은 규칙 — 제원 연장이 있으면 그것, 없으면 시·종점 거리.
|
||||
"length_m": stated if stated else span,
|
||||
"length_basis": "제원 연장" if stated else ("시·종점" if span else ""),
|
||||
"memo": item.get("memo") or "",
|
||||
"options": options,
|
||||
},
|
||||
)
|
||||
|
||||
for point in pipe_points:
|
||||
facility = str(point.get("facility") or "pipe")
|
||||
type_id = FACILITY_TYPES.get(facility, facility)
|
||||
chainage = float(point.get("chainage_m") or 0.0)
|
||||
length = _nearest(lengths, chainage) if type_id == "pipe" else None
|
||||
add(
|
||||
type_id,
|
||||
{
|
||||
"id": pipe_row_id(point),
|
||||
"origin": "pipe_points",
|
||||
"chainage_m": chainage,
|
||||
"start_m": point.get("start_m"),
|
||||
"end_m": point.get("end_m"),
|
||||
"count": 1,
|
||||
"length_m": length,
|
||||
"length_basis": "B06 횡단 관 연장" if length else "",
|
||||
"memo": "",
|
||||
"options": dict(point.get("options") or {}),
|
||||
},
|
||||
)
|
||||
|
||||
tables = []
|
||||
for type_id, definition in types.items():
|
||||
rows = rows_by_type.get(type_id)
|
||||
if not rows:
|
||||
continue
|
||||
rows.sort(key=lambda row: float(row.get("chainage_m") or row.get("start_m") or 0.0))
|
||||
columns = [
|
||||
{
|
||||
"key": f.key,
|
||||
"label": f.label,
|
||||
"unit": f.unit or "",
|
||||
"input": f.input,
|
||||
"choices": list(f.choices),
|
||||
"editable": f.phase == "detail" and f.enabled,
|
||||
}
|
||||
for f in definition.options
|
||||
]
|
||||
averages = {}
|
||||
for column in columns:
|
||||
values = [_number(row["cells"][column["key"]]["value"]) for row in rows]
|
||||
present = [v for v in values if v is not None]
|
||||
if present and column["input"] == "number":
|
||||
averages[column["key"]] = sum(present) / len(present)
|
||||
replaced = sum(1 for row in rows if row.get("replaced"))
|
||||
if replaced:
|
||||
notes.append(
|
||||
f"{definition.name} {replaced}줄 — 이 표에서 고친 값을 B05 가 바꿔 "
|
||||
"자동값으로 돌아감"
|
||||
)
|
||||
measured = [row["length_m"] for row in rows if row["length_m"]]
|
||||
# 연장이 뜻 있는 표만 빈 연장을 셈 — 반사경·표지판 같은 점 시설은 연장이 없는 것이 정상.
|
||||
needs_length = definition.placement == "interval" or type_id == "pipe"
|
||||
tables.append(
|
||||
{
|
||||
"type_id": type_id,
|
||||
"name": definition.name,
|
||||
"group": definition.group,
|
||||
"placement": definition.placement,
|
||||
"columns": columns,
|
||||
"rows": rows,
|
||||
"count": len(rows),
|
||||
"length_total_m": sum(measured) if measured else None,
|
||||
"length_missing": len(rows) - len(measured) if needs_length else 0,
|
||||
"averages": averages,
|
||||
}
|
||||
)
|
||||
return {"tables": tables, "notes": notes}
|
||||
|
||||
|
||||
def pipe_row_id(point: dict[str, Any]) -> str:
|
||||
"""계곡 통과 시설 줄 id — 관 지점엔 식별자가 없어 기준점(㎜ 자리)으로 가름."""
|
||||
return f"pipe@{float(point.get('chainage_m') or 0.0):.3f}"
|
||||
|
||||
|
||||
def _type_of(target: dict[str, Any], is_pipe: bool) -> str:
|
||||
if is_pipe:
|
||||
facility = str(target.get("facility") or "pipe")
|
||||
return FACILITY_TYPES.get(facility, facility)
|
||||
return str(target.get("type_id") or "")
|
||||
|
||||
|
||||
def coerce(field: Any, value: Any, name: str) -> Any:
|
||||
"""칸 값 꼴 맞추기 — B05 저장 관문과 같은 규칙(수 칸은 0 이상 수 · 고르기 칸은 보기 안).
|
||||
|
||||
빈 값(None·"")은 `None` = 그 칸을 지움. 틀리면 `ValueError`.
|
||||
"""
|
||||
if _blank(value):
|
||||
return None
|
||||
if field.input == "number":
|
||||
number = _number(value)
|
||||
if number is None or not math.isfinite(number) or number < 0:
|
||||
raise ValueError(f"{name} {field.label}: 0 이상의 수여야 함 — {value!r}")
|
||||
return int(number) if number.is_integer() else number
|
||||
# 수로 온 보기 값(뒷길이 35 → 35.0)은 보기 글과 같은 꼴로 — 「35.0」은 보기 「35」와 안 맞음.
|
||||
whole = isinstance(value, float) and value.is_integer()
|
||||
text = str(int(value)) if whole else str(value).strip()
|
||||
if field.input == "select" and field.choices and text not in field.choices:
|
||||
raise ValueError(f"{name} {field.label}: 보기에 없는 값 — {text}")
|
||||
return text
|
||||
|
||||
|
||||
def apply_edits(
|
||||
structures: list[dict[str, Any]],
|
||||
points: list[dict[str, Any]],
|
||||
types: dict[str, Any],
|
||||
edits: Iterable[dict[str, Any]],
|
||||
marks: dict[str, dict[str, Any]],
|
||||
) -> tuple[list[str], dict[str, dict[str, Any]]]:
|
||||
"""손 고침을 **정본 사본에 바로** 얹음(제자리 바꿈) — (바뀐 줄 id, 새 손댄 칸 표).
|
||||
|
||||
고치기 전 값(`was`)은 처음 손댄 때의 정본 값을 지킴 — 그 값으로 되돌리면 표에서 빠져 「자동」.
|
||||
놓기 칸·없는 줄·틀린 값은 `LookupError`/`ValueError` — 부르는 쪽이 아무것도 안 씀.
|
||||
"""
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureSheet_Edit import EDITABLE_KEYS as SHEET_KEYS
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureSheet_Edit import clean_spec
|
||||
|
||||
rows = {str(item.get("structure_id")): (item, False) for item in structures}
|
||||
rows |= {pipe_row_id(point): (point, True) for point in points}
|
||||
marks = {row_id: dict(cells) for row_id, cells in marks.items()}
|
||||
changed: list[str] = []
|
||||
for edit in edits:
|
||||
row_id, key = str(edit.get("id")), str(edit.get("key"))
|
||||
if row_id not in rows:
|
||||
raise LookupError(f"집계표 줄을 못 찾음: {row_id}")
|
||||
target, is_pipe = rows[row_id]
|
||||
definition = types.get(_type_of(target, is_pipe))
|
||||
field = next((f for f in definition.options if f.key == key), None) if definition else None
|
||||
if field is None:
|
||||
raise ValueError(f"{row_id}: 그 종류에 없는 칸 — {key}")
|
||||
if field.phase != "detail":
|
||||
raise ValueError(
|
||||
f"{definition.name} {field.label}: 자리·길이·높이 같은 놓기 칸은 "
|
||||
"시·종점과 한 벌이라 구조물 놓기(B05)에서 고침"
|
||||
)
|
||||
value = coerce(field, edit.get("value"), definition.name)
|
||||
if value is not None and key in SHEET_KEYS:
|
||||
# 구조물도 [제원 저장]과 **같은 꼴**로 적음(뒷길이는 정수 등) — 두 길의 꼴이 같아야 함.
|
||||
# ⚠ 검사(`coerce`)를 먼저 — `clean_spec` 은 못 읽은 값을 빼 버려 「지우기」로 읽힘.
|
||||
value = clean_spec({key: value})[0].get(key, value)
|
||||
options = dict(target.get("options") or {})
|
||||
old = None if _blank(options.get(key)) else options[key]
|
||||
if (value is None and old is None) or (value is not None and _same(old, value)):
|
||||
continue
|
||||
if value is None:
|
||||
options.pop(key, None)
|
||||
else:
|
||||
options[key] = value
|
||||
target["options"] = options
|
||||
cells = marks.setdefault(row_id, {})
|
||||
mark = cells.get(key)
|
||||
# 옛 손 표가 정본과 같을 때만 그 「고치기 전」을 이어 씀 — B05 가 바꾼 뒤면 지금 값이 기준.
|
||||
was = mark.get("was") if mark and _same(mark.get("value"), old) else old
|
||||
if _same(was, value):
|
||||
cells.pop(key, None)
|
||||
else:
|
||||
cells[key] = {"value": value, "was": was}
|
||||
if not cells:
|
||||
marks.pop(row_id)
|
||||
if row_id not in changed:
|
||||
changed.append(row_id)
|
||||
return changed, marks
|
||||
|
||||
|
||||
def prune_marks(
|
||||
marks: dict[str, dict[str, Any]],
|
||||
structures: Iterable[dict[str, Any]],
|
||||
points: Iterable[dict[str, Any]],
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""[저장] 때 손댄 칸 표 정리 — 없어진 줄과 **B05 가 이미 바꾼 칸**(알림을 본 뒤)은 뺌."""
|
||||
current = {str(item.get("structure_id")): item for item in structures}
|
||||
current |= {pipe_row_id(point): point for point in points}
|
||||
kept: dict[str, dict[str, Any]] = {}
|
||||
for row_id, cells in marks.items():
|
||||
if row_id not in current:
|
||||
continue
|
||||
options = current[row_id].get("options") or {}
|
||||
alive = {
|
||||
key: mark
|
||||
for key, mark in cells.items()
|
||||
if _same(None if _blank(options.get(key)) else options[key], mark.get("value"))
|
||||
}
|
||||
if alive:
|
||||
kept[row_id] = alive
|
||||
return kept
|
||||
|
||||
|
||||
def pipe_lengths_from_designs(designs: Iterable[dict[str, Any]]) -> dict[float, float]:
|
||||
"""저장된 횡단 설계 → `{측점: 관 연장}` — 배수관 물량과 같은 읽기."""
|
||||
from B08_Quantity.B08_Quantity_Engine_Pipe import _length_by_chainage
|
||||
|
||||
return _length_by_chainage(list(designs or []), "pipe_length_m")
|
||||
@@ -12,11 +12,15 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
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`).
|
||||
@@ -24,11 +28,13 @@ OVERRIDES_KEY = "structure_formula_overrides"
|
||||
|
||||
|
||||
def load_template(type_id: str) -> dict[str, Any] | None:
|
||||
"""프로그램 기본 양식. 없는 종류면 `None` — 그 종류는 지금 전개(고정형 모양)로 섬."""
|
||||
path = TEMPLATE_DIR / f"{type_id}.json"
|
||||
if not path.is_file():
|
||||
return None
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
"""프로그램 기본 양식. 없는 종류면 `None` — 그 종류는 지금 전개(고정형 모양)로 섬.
|
||||
|
||||
storage 작업본(시스템 관리자가 고친 것)이 먼저, 없으면 git 씨앗(`TEMPLATE_DIR`) — 2026-09-14 관리자 UI.
|
||||
"""
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import program_items
|
||||
|
||||
return next((item for item in program_items() if item.get("type_id") == type_id), None)
|
||||
|
||||
|
||||
def template_of(type_id: str, templates: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
@@ -75,6 +81,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
|
||||
|
||||
@@ -143,6 +155,65 @@ def template_sheet(
|
||||
#: 양식이 m당으로 풀리는 단위 — 연장 L=1 로 풀면 곧 단위당 값(모든 줄이 L 에 비례).
|
||||
_PER_LENGTH_UNITS = frozenset({"m"})
|
||||
|
||||
#: ㉯ 양식에 **그 이름 줄이 없으면 전개 값을 남기는** 성분(2026-09-14 브레인 판정 · 목록으로 못박음).
|
||||
#: STmate 호표(고정형)는 토공·버림을 안 품음 — 통째로 갈음하면 구조물터파기 85.25→25㎥(−6,037,712원 실측).
|
||||
#: 목록 밖 성분은 양식으로 갈음 · 목록 안이라도 양식이 그 줄을 품으면 양식 값(겹쳐 세지 않음).
|
||||
#: ② 실무 엑셀 벽·돌쌓기 호표 57 전수 — 기초잡석 0/57(「기초다짐 및 뒤채움」 4 은 다른 것) ·
|
||||
#: 채집석 = 사토에서 빼는 돌 부피(haul_deduction)라 호표의 채집 줄(품 · 41/57)과 다른 것.
|
||||
KEEP_ENGINE_COMPONENTS = ("터파기", "되메우기", "잔토처리", "버림콘크리트", "기초잡석", "채집석")
|
||||
KEPT_ROW_REASON = "전개 값 그대로 — 양식이 이 줄을 안 품음(토공·버림은 전개가 셈)"
|
||||
|
||||
|
||||
def _kept(rows: list[Any], template_rows: list[dict[str, Any]], name_of: Any) -> list[Any]:
|
||||
"""목록 이름 중 양식에 없는 것만 — 줄(dict)·성분(Component) 둘 다 이름 꺼내는 손잡이로."""
|
||||
names = {str(row.get("name") or "") for row in template_rows}
|
||||
return [
|
||||
row for row in rows if name_of(row) in KEEP_ENGINE_COMPONENTS and name_of(row) not in names
|
||||
]
|
||||
|
||||
|
||||
#: ㉰ 고정형 장·구조물 사유 머리 — 남긴 전개 줄에 걸린 전개 사유 / ㉱ 규격 다름.
|
||||
KEPT_NOTE_HEAD = "(남긴 전개 줄) "
|
||||
MISMATCH_HEAD = "⚠ 규격 다름"
|
||||
#: ㉱ 항목 제목에 적힌 높이 규격 「H=2.0m」 — 원문 제목의 값을 읽음(없으면 비교 안 함 · 추측 아님).
|
||||
_SPEC_HEIGHT = re.compile(r"H\s*=\s*([0-9]+(?:\.[0-9]+)?)")
|
||||
|
||||
|
||||
def _is_fixed(template: dict[str, Any]) -> bool:
|
||||
"""고정형 — 식이 한 줄도 없음(명세 13장 · `StructureLibrary.item_kind` 와 같은 가름)."""
|
||||
rows = template.get("rows") or []
|
||||
return bool(rows) and not any(str(row.get("formula") or "").strip() for row in rows)
|
||||
|
||||
|
||||
def fixed_notes(
|
||||
template: dict[str, Any], engine_notes: list[str], height: Any
|
||||
) -> tuple[list[str], dict[str, float] | None]:
|
||||
"""㉰㉱ 고정형의 사유 — 항목 사유 + **남긴 전개 줄에 걸린** 전개 사유만 + 규격 다름(맨 앞).
|
||||
|
||||
전개 사유(뒷길이 표준·돌 종류…)는 고정형 줄과 안 맞아 거짓 사유 → 걷음. 남긴 줄(터파기·버림…)을
|
||||
말하는 것만 머리를 달아 둠. 두 번 불려도(원단위 → 구조물도) 같은 값이 나오게 함.
|
||||
"""
|
||||
item = [part for part in str(template.get("note") or "").split(" · ") if part]
|
||||
kept: list[str] = []
|
||||
for note in engine_notes:
|
||||
if note in item or note.startswith(MISMATCH_HEAD):
|
||||
continue
|
||||
if note.startswith(KEPT_NOTE_HEAD):
|
||||
kept.append(note)
|
||||
elif any(name in note for name in KEEP_ENGINE_COMPONENTS):
|
||||
kept.append(KEPT_NOTE_HEAD + note)
|
||||
notes = list(dict.fromkeys(item + kept))
|
||||
found = _SPEC_HEIGHT.search(str(template.get("name") or ""))
|
||||
if not found or height is None or abs(float(found.group(1)) - float(height)) < 1e-6:
|
||||
return notes, None
|
||||
item_h, sheet_h = float(found.group(1)), float(height)
|
||||
notes.insert(
|
||||
0,
|
||||
f"{MISMATCH_HEAD} — 항목 H={found.group(1)}m ↔ 장 H={sheet_h:g}m: 벽 수량은 항목 박힌 값"
|
||||
f"(H={found.group(1)}m) · 토공·사토 공제는 장 제원(H={sheet_h:g}m)으로 셈",
|
||||
)
|
||||
return notes, {"item_height_m": item_h, "sheet_height_m": sheet_h}
|
||||
|
||||
|
||||
def _library_rows(
|
||||
body: dict[str, Any], solved: list[dict[str, Any]], billing: float
|
||||
@@ -217,7 +288,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 +300,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,
|
||||
@@ -287,7 +356,11 @@ def replace_with_templates(
|
||||
spec=str(result.get("spec") or ""),
|
||||
)
|
||||
)
|
||||
quantity.components = components
|
||||
quantity.components = components + _kept(
|
||||
quantity.components, body["rows"], lambda component: component.name
|
||||
)
|
||||
if _is_fixed(template):
|
||||
quantity.notes, _mismatch = fixed_notes(template, quantity.notes, quantity.height_m)
|
||||
# 「양식 있음/없음」을 화면이 가리게 — 조용히 섞이면 왜 값이 다른지 못 찾음.
|
||||
quantity.library_item = str(template.get("name") or quantity.type_id)
|
||||
|
||||
@@ -335,18 +408,41 @@ def apply_templates(
|
||||
continue
|
||||
members = sheet.get("members") or []
|
||||
billing = float(members[0].get("billing_quantity") or 0.0) if members else 0.0
|
||||
sheet["rows"] = _library_rows(body, solved[index], billing)
|
||||
library_rows = _library_rows(body, solved[index], billing)
|
||||
start = max((row["no"] for row in library_rows), default=0)
|
||||
kept = _kept(sheet.get("rows") or [], body["rows"], lambda row: row.get("name"))
|
||||
sheet["rows"] = library_rows + [
|
||||
{
|
||||
**row,
|
||||
"no": start + offset,
|
||||
"formula": "",
|
||||
"default_formula": "",
|
||||
"rounding": None,
|
||||
"default_rounding": None,
|
||||
"skipped": False,
|
||||
"reason": KEPT_ROW_REASON,
|
||||
"error": "",
|
||||
}
|
||||
for offset, row in enumerate(kept, start=1)
|
||||
]
|
||||
sheet["unpriced_rows"] = [
|
||||
row["name"]
|
||||
for row in sheet["rows"]
|
||||
if row["unit_amount"] is None and not row["skipped"]
|
||||
]
|
||||
if _is_fixed(template):
|
||||
# ㉱ 규격 다름 — 금액은 서되 미확정 급으로 보임(막지 않음 · 사용자가 일부러 고른 것일 수 있음).
|
||||
sheet["notes"], sheet["spec_mismatch"] = fixed_notes(
|
||||
template, sheet.get("notes") or [], sheet.get("height_m")
|
||||
)
|
||||
# 「어느 단에서 가져왔나」 — 안 가져왔으면 `imported_from` 없음(= 기본 · 가져오기 전).
|
||||
sheet["library_item"] = {
|
||||
"type_id": template.get("type_id"),
|
||||
"name": template.get("name"),
|
||||
"code": template.get("code"),
|
||||
"imported_from": template.get("imported_from"),
|
||||
# 발행 확인창이 「○○공사에서 뽑은 것 — 공사명은 빼고 발행」을 보이게(브레인 ②).
|
||||
"origin_project": (template.get("origin") or {}).get("project"),
|
||||
}
|
||||
sheet["formula_sheet"] = body
|
||||
# 실무 관측값 같은 「대안 후보」 — 값을 바꾸지 않고 칸 옆에 보이기만(판정 Ⓑ).
|
||||
|
||||
@@ -2,16 +2,21 @@
|
||||
|
||||
⚠ 값을 여기서 새로 짓지 않음 — 줄 단가는 B09 `PriceBook.resolve`(읽기만), 수량은 원단위 줄 값.
|
||||
⚠ 못 푼 줄은 0 이 아니라 **막힘 + 까닭** · 막힌 줄이 하나라도 있으면 합계는 「미완」.
|
||||
⚠ 반올림은 B09 자리 규칙 그대로 — 금액란 0.1원 미만 버림 · 계금 1원 미만 버림(품셈 1-2-2).
|
||||
하위 일위대가를 윗 표에 넣을 때는 **안 자른 값**을 씀 — B09 `resolve` 도 조립 중엔 안 자름.
|
||||
⚠ 반올림은 B09 자리 규칙 그대로 — 금액란 0.1원 미만 버림 · 성분 소계 1원 미만 버림(명세 7장).
|
||||
하위 일위대가를 윗 표에 넣을 때도 **자른 성분 소계**를 씀 — B09 `resolve` 의 B 호표와 같음.
|
||||
⚠ **재귀** — 줄이 `B-AX-ST-*`(다른 구조물 양식)를 가리키면 그 양식을 제원(`sub_vars`)으로 풀어
|
||||
일위대가를 먼저 세우고 그 단위당 금액을 씀. 레시피 150/350 이 2단 이상(명세 16장).
|
||||
깊이 **5단**까지(PLAN 10장 판정) · 돌면 막힘. B-FP·X·L 쪽 재귀는 단가표가 이미 함.
|
||||
⛔ 하위 양식은 **프로젝트에 박힌 것과 프로그램 기본**에서만 찾음 — 개인·회사 단은 안 읽음(4장 Ⓑ).
|
||||
⚠ **저장 자리는 둘**(브레인 판정 2026-09-13) — 줄 더하기·빼기·고르개로 고친
|
||||
**줄 조합은 양식+프로젝트**(`ROWS_KEY`, 종류별 · [내 라이브러리에 저장] 때 양식에 실림) ·
|
||||
**수동 단가는 프로젝트만**(`MANUAL_KEY` · 양식에 실으면 내 단가가 남의 프로젝트로 감).
|
||||
수동 단가 줄은 「미확정」으로 셈.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Callable, Iterable
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
@@ -21,6 +26,15 @@ from typing import Any
|
||||
SUB_STRUCTURE_PREFIX = "B-AX-ST-"
|
||||
#: 재귀 깊이 한도 — 맨 윗 표가 1단(PLAN 10장 「재귀 깊이 = 5단」).
|
||||
MAX_DEPTH = 5
|
||||
#: 산출 조건 자리 — 고친 줄 조합 `{type_id: [rows]}` · 수동 단가 `{type_id: {seq: 값}}`.
|
||||
ROWS_KEY = "structure_unit_price_rows"
|
||||
MANUAL_KEY = "structure_manual_prices"
|
||||
#: 고르개 갈래 — 품셈(일위대가·단가산출) · 자원(자재·노임·시간당 중기).
|
||||
SEARCH_KINDS = {
|
||||
"work": ("unit_price", "price_basis"),
|
||||
"resource": ("material", "labor", "machine_hourly"),
|
||||
}
|
||||
_MONEY_KEYS = ("material", "labor", "expense")
|
||||
_UNIT_ALIASES = {"m2": "㎡", "m3": "㎥", "M2": "㎡", "M3": "㎥", "M": "m"}
|
||||
|
||||
|
||||
@@ -34,6 +48,8 @@ class _Context:
|
||||
book: Any
|
||||
find_variant: Callable[[str, str], str | None]
|
||||
library: dict[str, dict[str, Any]]
|
||||
#: 맨 윗 표의 수동 단가 `{seq: 값}` — 하위 양식에는 안 씀(그 종류의 줄이 아님).
|
||||
manual: dict[str, dict[str, Any]]
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -151,8 +167,8 @@ def _assemble(
|
||||
values = (sheet.get("formula_sheet") or {}).get("vars") or {}
|
||||
rows: list[dict[str, Any]] = []
|
||||
sums = {"material": Decimal(0), "labor": Decimal(0), "expense": Decimal(0)}
|
||||
exact = Money3()
|
||||
blocked = 0
|
||||
unconfirmed = 0
|
||||
for row in spec.get("rows") or []:
|
||||
out: dict[str, Any] = {
|
||||
"seq": row.get("seq"),
|
||||
@@ -165,6 +181,15 @@ def _assemble(
|
||||
"reason": "",
|
||||
}
|
||||
source = sheet_rows.get(row.get("from_row")) if "from_row" in row else None
|
||||
# 이중계상 경계 ①(명세 6장) — 토공집계·사토 공제·참고로 가는 줄을 일위대가에 또 넣지 않음.
|
||||
from B08_Quantity.B08_Quantity_Engine_Handoff_Boundaries import NOT_BILLED_BY_WORK_ITEM
|
||||
|
||||
owner = NOT_BILLED_BY_WORK_ITEM.get(str((source or {}).get("destination") or ""))
|
||||
if owner:
|
||||
out["reason"] = f"이중계상 — 원단위 줄 {row['from_row']} 은 {owner}로 가는 줄"
|
||||
blocked += 1
|
||||
rows.append(out)
|
||||
continue
|
||||
if source is not None and (source.get("skipped") or source.get("error")):
|
||||
# 원단위 줄이 안 선 장이면 일위대가 줄도 안 섬 — 막힘이 아님(버림 「안 넣음」 등).
|
||||
out.update(skipped=bool(source.get("skipped")), reason=source.get("reason") or "")
|
||||
@@ -193,7 +218,18 @@ def _assemble(
|
||||
ref, why = _ref_of(row, values, ctx.find_variant)
|
||||
out["ref_code"] = ref or ""
|
||||
priced = None
|
||||
if ref:
|
||||
manual = ctx.manual.get(str(row.get("seq"))) if depth == 1 else None
|
||||
if manual:
|
||||
# 수동 단가가 단가표보다 이김 — 사람이 일부러 넣은 값. 대신 「미확정」으로 셈.
|
||||
money = Money3(*(Decimal(str(manual.get(key) or 0)) for key in _MONEY_KEYS))
|
||||
priced = _Priced(money, out["name"], out["spec"], out["unit"])
|
||||
out.update(
|
||||
manual=True,
|
||||
manual_source=manual.get("source") or "",
|
||||
manual_entered_at=manual.get("entered_at") or "",
|
||||
)
|
||||
unconfirmed += 1
|
||||
elif ref:
|
||||
try:
|
||||
priced = _price(ref, row, ctx, depth, seen)
|
||||
if out["unit"] and priced.unit and priced.unit != out["unit"]:
|
||||
@@ -208,7 +244,6 @@ def _assemble(
|
||||
continue
|
||||
|
||||
money = priced.money
|
||||
exact = exact + money.scaled(quantity)
|
||||
cells = {
|
||||
"material": round_at(money.material * quantity, OutputPlace.UNIT_PRICE_ROW),
|
||||
"labor": round_at(money.labor * quantity, OutputPlace.UNIT_PRICE_ROW),
|
||||
@@ -233,13 +268,21 @@ def _assemble(
|
||||
"name": template.get("name") or "",
|
||||
"unit": sheet.get("billing_unit") or "",
|
||||
"rows": rows,
|
||||
**{key: float(value) for key, value in sums.items()},
|
||||
# 계금 — 1원 미만 버림. ⚠ 막힌 줄이 있으면 이 값은 「미완」이라 화면이 그렇게 적음.
|
||||
"total": float(round_at(sum(sums.values()), OutputPlace.UNIT_PRICE_TOTAL)),
|
||||
# 성분 소계 — 성분마다 1원 미만 버림(명세 7장 · B09 일위대가 호표와 같은 규칙).
|
||||
**{
|
||||
key: float(round_at(value, OutputPlace.UNIT_PRICE_TOTAL)) for key, value in sums.items()
|
||||
},
|
||||
# 계금 = 자른 성분 소계의 합. ⚠ 막힌 줄이 있으면 이 값은 「미완」이라 화면이 그렇게 적음.
|
||||
"total": float(
|
||||
sum(round_at(value, OutputPlace.UNIT_PRICE_TOTAL) for value in sums.values())
|
||||
),
|
||||
"blocked": blocked,
|
||||
"complete": blocked == 0,
|
||||
#: 수동 단가로 선 줄 수 — 화면 「미확정 N건」 배지.
|
||||
"unconfirmed": unconfirmed,
|
||||
}
|
||||
return table, exact
|
||||
# 윗 표·내역이 쓸 단위당 3분할 = **자른 성분 소계**(하위 호표 합계를 그대로 부름 — 명세 7장).
|
||||
return table, Money3(*(Decimal(str(table[key])) for key in _MONEY_KEYS))
|
||||
|
||||
|
||||
def unit_price_table(
|
||||
@@ -248,14 +291,136 @@ def unit_price_table(
|
||||
book: Any,
|
||||
find_variant: Callable[[str, str], str | None],
|
||||
library: Iterable[dict[str, Any]] = (),
|
||||
manual: dict[str, dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""장 하나의 일위대가 표. 양식에 `unit_price` 가 없으면 `None`.
|
||||
|
||||
수량 — `from_row`(원단위 줄 차례 → 그 줄의 단위당 값) 또는 박힌 `quantity`.
|
||||
코드 — `ref_code`(단가표 코드 그대로) 또는 `work_item_code` + `variant_from`(제원 칸 → 갈래).
|
||||
`library` — 하위 구조물 일위대가(`B-AX-ST-*`)를 찾을 양식들(프로젝트에 박힌 것 + 프로그램 기본).
|
||||
`manual` — 이 프로젝트의 수동 단가 `{seq: {material, labor, expense, source, entered_at}}`.
|
||||
"""
|
||||
ctx = _Context(book, find_variant, {str(t["code"]): t for t in library if t.get("code")})
|
||||
code = str(template.get("code") or "")
|
||||
assembled = _assemble(template, sheet, ctx, 1, (code,) if code else ())
|
||||
assembled = unit_price_money(template, sheet, book, find_variant, library, manual)
|
||||
return assembled[0] if assembled else None
|
||||
|
||||
|
||||
def unit_price_money(
|
||||
template: dict[str, Any],
|
||||
sheet: dict[str, Any],
|
||||
book: Any,
|
||||
find_variant: Callable[[str, str], str | None],
|
||||
library: Iterable[dict[str, Any]] = (),
|
||||
manual: dict[str, dict[str, Any]] | None = None,
|
||||
) -> tuple[dict[str, Any], Any] | None:
|
||||
"""`unit_price_table` 과 같되 **단위당 3분할**(자른 성분 소계)도 — B09 내역 줄이 이 값에 수량을 곱함."""
|
||||
ctx = _Context(
|
||||
book, find_variant, {str(t["code"]): t for t in library if t.get("code")}, manual or {}
|
||||
)
|
||||
code = str(template.get("code") or "")
|
||||
return _assemble(template, sheet, ctx, 1, (code,) if code else ())
|
||||
|
||||
|
||||
def with_rows(template: dict[str, Any], rows: list[dict[str, Any]] | None) -> dict[str, Any]:
|
||||
"""고친 줄 조합을 얹은 양식 — 고친 적 없으면 양식 줄. 줄 없는 양식도 빈 표로 세움."""
|
||||
spec = template.get("unit_price") or {}
|
||||
picked = (spec.get("rows") or []) if rows is None else rows
|
||||
return {**template, "unit_price": {**spec, "rows": picked}}
|
||||
|
||||
|
||||
def save_rows(
|
||||
current: dict[str, Any], type_id: str, template: dict[str, Any], rows: list[dict[str, Any]]
|
||||
) -> tuple[dict[str, Any], bool]:
|
||||
"""줄 조합 저장본 — 양식과 같으면 그 종류를 지움(양식대로). (새 저장본, 바뀜)."""
|
||||
merged = {key: value for key, value in current.items() if key != type_id}
|
||||
if rows != ((template.get("unit_price") or {}).get("rows") or []):
|
||||
merged[type_id] = rows
|
||||
return merged, merged != current
|
||||
|
||||
|
||||
def save_manual(
|
||||
current: dict[str, Any],
|
||||
type_id: str,
|
||||
rows: list[dict[str, Any]],
|
||||
prices: dict[str, dict[str, Any]],
|
||||
today: str,
|
||||
) -> dict[str, Any]:
|
||||
"""수동 단가 저장본 — **남은 줄의 것만** 둠(뺀 줄의 값이 같은 차례 새 줄에 붙지 않게).
|
||||
|
||||
넣은 날짜는 값·출처가 그대로면 옛 날짜를 둠 — 다시 저장했다고 날짜가 새로 서면 안 됨.
|
||||
"""
|
||||
seqs = {str(row.get("seq")) for row in rows}
|
||||
before = current.get(type_id) or {}
|
||||
kept = {}
|
||||
for seq, price in prices.items():
|
||||
if str(seq) not in seqs:
|
||||
continue
|
||||
old = before.get(str(seq)) or {}
|
||||
same = all(old.get(key) == price.get(key) for key in (*_MONEY_KEYS, "source"))
|
||||
kept[str(seq)] = {**price, "entered_at": old.get("entered_at") if same else today}
|
||||
merged = {key: value for key, value in current.items() if key != type_id}
|
||||
if kept:
|
||||
merged[type_id] = kept
|
||||
return merged
|
||||
|
||||
|
||||
#: 조각 후보의 최소 겹침 글자 수(2026-09-14 브레인 판정 ① — 「채집」·「타설」이 걸리게).
|
||||
MIN_FRAGMENT = 2
|
||||
|
||||
|
||||
def _longest_common(a: str, b: str) -> int:
|
||||
"""두 글의 가장 긴 공통 조각 길이."""
|
||||
best = 0
|
||||
previous = [0] * (len(b) + 1)
|
||||
for char in a:
|
||||
current = [0]
|
||||
for index, other in enumerate(b):
|
||||
current.append(previous[index] + 1 if char == other else 0)
|
||||
best = max(best, max(current))
|
||||
previous = current
|
||||
return best
|
||||
|
||||
|
||||
def search_titles(book: Any, query: str, kind: str, limit: int = 50) -> list[dict[str, Any]]:
|
||||
"""고르개 — 단가표 항목 후보. 단가가 섰는지도 함께.
|
||||
|
||||
낱말이 **모두** 든(코드·이름·규격) 항목이 먼저, 그다음 **두 글자 이상 조각이 겹친** 항목을
|
||||
겹친 글자 수 순으로(①). 고정형 줄 이름(깬잡석채집)과 단가표 제목(막돌 채집)은 낱말이 달라
|
||||
전부 포함 방식으로는 후보가 0 이었음. ⚠ 후보만 — 고르는 것은 사람(명세 2장 · 별칭표로 안 맞춤).
|
||||
"""
|
||||
from B09_Estimation.B09_Estimation_PriceBook import PriceBookError
|
||||
|
||||
kinds = SEARCH_KINDS[kind]
|
||||
words = query.lower().split()
|
||||
# 조각 비교는 글자·숫자만 — 괄호 「(장비)」 같은 기호가 겹친 글자 수를 부풀리지 않게.
|
||||
compact = re.sub(r"[\W_]+", "", query.lower())
|
||||
scored: list[tuple[int, int, int, Any]] = []
|
||||
for order, title in enumerate(book.titles.values() if words else ()):
|
||||
if title.kind.value not in kinds:
|
||||
continue
|
||||
text = f"{title.code} {title.name} {title.spec}".lower()
|
||||
if all(word in text for word in words):
|
||||
scored.append((1, len(compact), order, title))
|
||||
continue
|
||||
overlap = _longest_common(
|
||||
compact, re.sub(r"[\W_]+", "", f"{title.name}{title.spec}".lower())
|
||||
)
|
||||
if overlap >= MIN_FRAGMENT:
|
||||
scored.append((0, overlap, order, title))
|
||||
scored.sort(key=lambda item: (-item[0], -item[1], item[2]))
|
||||
found: list[dict[str, Any]] = []
|
||||
for _whole, _overlap, _order, title in scored[:limit]:
|
||||
try:
|
||||
money = book.resolve(title.code)
|
||||
total: float | None = float(money.material + money.labor + money.expense)
|
||||
except PriceBookError:
|
||||
total = None
|
||||
found.append(
|
||||
{
|
||||
"code": title.code,
|
||||
"name": title.name,
|
||||
"spec": title.spec,
|
||||
"unit": _unit(title.unit),
|
||||
"price": total,
|
||||
}
|
||||
)
|
||||
return found
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,165 @@
|
||||
"""구조물 원단위 — **성분 계약과 갈 곳**(이중계상 경계) · 2026-09-14 `UnitQuantity` 에서 가름.
|
||||
|
||||
⚠⚠ 이 파일의 `DESTINATION`·`MIX_COMPONENTS` 가 이중계상 경계다 — 규칙 셋은 `UnitQuantity` 머리글.
|
||||
㉢ 배합을 분해하지 않음(`MIX_COMPONENTS`) · ㉠ 할증 안 붙임 ·
|
||||
터파기·되메우기·잔토는 토공으로(`DESTINATION`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from B08_Quantity.B08_Quantity_Engine_ObservedUnit import BASIS_DERIVED
|
||||
|
||||
#: 기초잡석 줄 이름 — 갈 곳 표(`DESTINATION`)가 이 이름을 키로 씀. 두께 상수는 `_StoneSpec`.
|
||||
RUBBLE_BASE_NAME = "기초잡석"
|
||||
|
||||
|
||||
DESTINATION = {
|
||||
"터파기": "earthwork",
|
||||
"되메우기": "earthwork",
|
||||
"잔토처리": "earthwork",
|
||||
"돌쌓기": "unit_price",
|
||||
"돌붙임": "unit_price",
|
||||
"깬돌": "material",
|
||||
"고임돌": "material",
|
||||
"막자갈": "material",
|
||||
# ⭐ 2026-09-09 사용자 확정 3차 ⑥ — **콘크리트는 자재 축에 세운다**(사용자 명시).
|
||||
# ⚠ 타설 줄(품셈 12-1)은 **품만** 주고 재료를 안 준다(서브 일위대가도 재료 0원).
|
||||
# 그래서 자재로 안 보내면 **재료비가 통째로 빠진다** — B09 매김에서 드러난 자리다.
|
||||
# ⚠ 배합을 분해하지 않는 규칙(㉢)은 그대로다 — 「콘크리트 ㎥」에서 멈춘다.
|
||||
"콘크리트": "material",
|
||||
"채움콘크리트": "material",
|
||||
"버림콘크리트": "material",
|
||||
"모르터": "unit_price",
|
||||
"거푸집": "unit_price",
|
||||
"물구멍관": "material",
|
||||
# ⚠ 자재도 토공도 아닌 자리 — **유토곡선이 사토에서 뺄 밑수**다. 자재총괄은
|
||||
# `material` 만 모으므로 여기 섞이지 않는다.
|
||||
"채집석": "haul_deduction", # ⚠ 자재 카탈로그가 이름으로 찾는다 — 공백 없는 한 낱말(B09 규약)
|
||||
# ⚠ **보여 주기만 하는 줄** — 자재도 토공도 일위대가도 아니다. 정본 계산표 좌측 열에
|
||||
# 「입적」으로 실려 있어 그 이름 그대로 낸다(2026-09-08 확정 ⑦). 값은 이미 식 안에
|
||||
# 있던 것을 줄로 꺼낸 것뿐이고, `material`·`earthwork` 어디에도 안 섞인다.
|
||||
# ⚠ 「석적」은 정본에 없고 소광리 시트에만 있으나 **확정 5차로 세운다** — 같은
|
||||
# 보여 주기 줄이고, 「소광리에만 있는 줄」임을 사유에 적는다.
|
||||
"입적": "reference",
|
||||
"석적": "reference",
|
||||
# 돌 줄은 **이름 「돌」 + 규격(`spec`)에 종류**다(명세 13장 Ⓒ) — 정본 계산표 줄 이름 그대로
|
||||
# (「돌 ℓ3=45cm」). 자재총괄이 (이름·규격·단위)로 묶으므로 종류 이름을 따로 두지 않는다.
|
||||
"돌": "material",
|
||||
# 기초잡석(품셈 12-25) — 운반·부설·다짐 품이 붙는 **공종**이라 일위대가로 간다.
|
||||
# 자재총괄로 보내면 같은 잡석이 재료로 한 번 더 선다.
|
||||
RUBBLE_BASE_NAME: "unit_price",
|
||||
# ⭐ 2026-09-14 — 갈 곳 기본값을 걷자 드러난 넷(L형수로·개거·떼흙막이 정본 줄).
|
||||
# 관측 원단위(`structure_unit_observed`)가 같은 이름에 둔 갈 곳과 맞춤(두 벌이 안 갈리게).
|
||||
# 철근은 이름 「이형철근」 + 규격 「D13·D16」(같은 날 가름 — 대응표 조각은 이름+규격 키로 집음).
|
||||
"이형철근": "material",
|
||||
"유로폼": "unit_price", # 거푸집 계열(품셈 12-38) — 설치·해체 품
|
||||
"면목": "material",
|
||||
"떼": "material", # 사면 떼(자재총괄 extra)와 같은 자리 · 할증 10%(1-3-1)
|
||||
}
|
||||
|
||||
#: ⛔ **갈 곳 기본값 금지**(명세 13장 「빠진 것이 조용해짐」) — 표에 없는 이름은 막고 사유를 냄.
|
||||
#: 종전엔 `DESTINATION.get(name, "quantity")` 로 떨어져 **아무 축에도 안 가고** 자재총괄
|
||||
#: 건너뛴 수로만 셈(2026-09-14 L형수로 이형철근·떼흙막이 떼가 통째로 빠져 있던 자리).
|
||||
DESTINATION_MISSING = (
|
||||
"「{name}」 갈 곳이 표(`DESTINATION`)에 없어 물량을 어느 축에도 안 보냄"
|
||||
" — 표에 올릴 것(명세 13장 갈 곳 기본값 금지)"
|
||||
)
|
||||
|
||||
|
||||
def routed(name: str, notes: list[str]) -> str | None:
|
||||
"""성분 이름의 갈 곳 — 표에 없으면 `None` 이고 사유 한 줄을 `notes` 에 더함(이름마다 한 번)."""
|
||||
destination = DESTINATION.get(name)
|
||||
if destination is None:
|
||||
text = DESTINATION_MISSING.format(name=name)
|
||||
if text not in notes:
|
||||
notes.append(text)
|
||||
return destination
|
||||
|
||||
|
||||
# ⚠ 배합 성분 — 산출물에 나타나면 안 된다(㉢). B09 일위대가가 배합표로 분해한다.
|
||||
# ⚠ **정확히 같은 이름**으로만 본다. 부분문자열로 재면 `막자갈`(뒤채움 재료)이 배합 `자갈` 로
|
||||
# 오탐된다 — 개발 중 실제로 걸렸던 자리다.
|
||||
MIX_COMPONENTS = frozenset(
|
||||
{"시멘트", "모래", "자갈", "친모래", "친자갈", "잔골재", "굵은골재", "부순돌"}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Component:
|
||||
"""전개 결과 한 성분. `basis` 는 어떤 식으로 나왔는지 사람이 읽는 근거다."""
|
||||
|
||||
name: str
|
||||
unit: str
|
||||
amount: float
|
||||
destination: str
|
||||
basis: str = ""
|
||||
# ⚠ 값이 어디서 왔나 — `derived`(치수에서 식으로) / `observed`(실무 관측 원단위표).
|
||||
# 두 근거가 한 표에 섞이므로 줄마다 단다. 안 적으면 나중에 못 되짚는다.
|
||||
basis_kind: str = BASIS_DERIVED
|
||||
source: str = ""
|
||||
#: 규격 — 같은 이름이라도 단가가 갈리는 값(채움 콘크리트 강도 등). 받는 쪽이 읽는다.
|
||||
spec: str = ""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class StructureQuantity:
|
||||
"""구조물 하나의 원단위 전개 결과."""
|
||||
|
||||
structure_id: str | None
|
||||
type_id: str
|
||||
name: str
|
||||
length_m: float = 0.0
|
||||
height_m: float = 0.0
|
||||
# 측점 — 내역 줄에 「어디부터 어디까지」를 적으려면 여기서 따라가야 한다(B09 인계).
|
||||
start_m: float | None = None
|
||||
end_m: float | None = None
|
||||
# 저장된 제원 — **형식(반중력식…)처럼 뒤 단계가 봐야 하는 값**이 여기 있다.
|
||||
# 치수를 다시 쓰라는 뜻이 아니라 **읽으라고** 실어 나른다(치수 정본은 여전히 하나).
|
||||
options: dict[str, Any] = field(default_factory=dict)
|
||||
components: list[Component] = field(default_factory=list)
|
||||
notes: list[str] = field(default_factory=list)
|
||||
#: 내역 줄이 설 **단위와 그 단위로 센 수량**. 관측 원단위가 「개소당」·「㎡당」인
|
||||
#: 종류는 연장(m)으로 세면 축이 어긋난다(2026-09-08 ㉕ 실증).
|
||||
#: 비어 있으면 종전대로 「m · 연장」으로 선다.
|
||||
billing_unit: str = ""
|
||||
billing_quantity: float = 0.0
|
||||
#: 구조물이 놓인 자리의 지반 갈래(`soil`·`ripping_rock`·`blasting_rock`)와 그 판정 근거.
|
||||
#: 품셈 9-13 구조물터파기의 **토질 축**이 이 값으로 갈린다. 못 가르면 `None` 이다.
|
||||
ground_type: str | None = None
|
||||
ground_type_basis: str = ""
|
||||
#: 파는 깊이(m) — 단면으로 터파기를 세운 종류만(옹벽 기초분). 비면 인계가 직고 + 기초 깊이로 봄.
|
||||
trench_depth_m: float | None = None
|
||||
#: 양식으로 성분을 세웠으면 그 양식 이름(PLAN 3장 ④-2). 비면 지금 전개 값.
|
||||
library_item: str = ""
|
||||
#: 치수가 정본에 없어 **등록부 기본값으로 선** 까닭(2026-09-14 브레인 판정) — 차 있으면 줄·표·그림은
|
||||
#: 서되 **금액·자재·토공·운반 합에는 안 듦**(`is_unconfirmed`). 설계자가 치수를 적으면 비고 금액이 섬.
|
||||
unconfirmed: str = ""
|
||||
|
||||
|
||||
def is_unconfirmed(structure: dict[str, Any]) -> bool:
|
||||
"""금액 합에서 뺄 구조물인가 — 합을 내는 자리마다 이 한 벌로 가림."""
|
||||
return bool(structure.get("unconfirmed"))
|
||||
|
||||
|
||||
#: 높이가 물량 밑수인 벽 — 높이를 안 적고 놓아도 줄은 서되 금액 밖(2026-09-14 브레인 판정 ①
|
||||
#: 「설계자가 자리부터 잡고 치수를 뒤에 넣는 길을 막지 말 것」). 흙막이(떼 개소당)는 높이를 안 씀.
|
||||
HEIGHT_DRIVEN_TYPES = frozenset({"retaining_wall", "masonry_wet", "masonry_dry", "boulder_masonry"})
|
||||
UNCONFIRMED_NO_HEIGHT = "높이를 안 적고 놓았음 — 높이를 적으면 금액이 섬"
|
||||
|
||||
|
||||
def missing_height_reason(structure: dict[str, Any]) -> str:
|
||||
"""높이 없이 놓인 벽이면 미확정 까닭 한 줄, 아니면 빈 글."""
|
||||
if structure.get("type_id") not in HEIGHT_DRIVEN_TYPES:
|
||||
return ""
|
||||
try:
|
||||
height = float((structure.get("options") or {}).get("height_m") or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
height = 0.0
|
||||
return "" if height > 0 else UNCONFIRMED_NO_HEIGHT
|
||||
|
||||
|
||||
def _num(value: Any, fallback: float = 0.0) -> float:
|
||||
return float(value) if isinstance(value, (int, float)) else fallback
|
||||
@@ -0,0 +1,532 @@
|
||||
"""구조물 원단위 — **돌쌓기·큰돌쌓기 전개식**(버림·터파기 줄 포함) · 2026-09-14 가름.
|
||||
|
||||
⚠ 식을 발명하지 않음 — 실무 정본 식을 옮긴 자리(`UnitQuantity` 머리글). 계수·판정은 `_StoneSpec`.
|
||||
⚠ 터파기·되메우기·잔토는 **토공으로 가는 줄**(`DESTINATION`) — 내역 줄로 두 번 세지 않음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Base import DESTINATION, Component, _num
|
||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_StoneSpec import (
|
||||
BLINDING_THICKNESS_M,
|
||||
STONE_BACK_LENGTH_TABLE,
|
||||
STONE_BACKFILL_VERIFIED_HEIGHT_M,
|
||||
STONE_BACKFILL_WIDTH_M,
|
||||
STONE_KIND_OPTION,
|
||||
STONE_MASONRY,
|
||||
STONE_PACKING_RATIO,
|
||||
STONE_PILE_BASIS,
|
||||
_back_length,
|
||||
back_length_default_note,
|
||||
dry_masonry_height_note,
|
||||
face_slope_ratio,
|
||||
fill_concrete_mpa,
|
||||
STONE_SUPPLY_DEFAULT_NOTE,
|
||||
STONE_SUPPLY_KEYS,
|
||||
is_collected_stone,
|
||||
load_stone_kind_table,
|
||||
stone_coefficients,
|
||||
stone_weight_per_m2,
|
||||
wall_thickness,
|
||||
wants_blinding,
|
||||
weep_hole_spec,
|
||||
)
|
||||
from common_util.common_util_excavation import (
|
||||
BASIS_WALL,
|
||||
WALL_BLINDING_DEPTH_M,
|
||||
WALL_FOUNDATION_DEPTH_M,
|
||||
WALL_TRENCH_CLEARANCE_M,
|
||||
wall_trench_area_m2,
|
||||
wall_trench_width_m,
|
||||
)
|
||||
|
||||
|
||||
def _blinding_component(
|
||||
base_width_m: float,
|
||||
length_m: float,
|
||||
options: dict[str, Any],
|
||||
width_basis: str = "",
|
||||
):
|
||||
"""버림 콘크리트 한 줄. 넣지 않기로 했으면 `None`.
|
||||
|
||||
폭은 **잡석다짐 폭**(KCS 34 50 05)이고, 그 값은 확정 ⑪ 로 **구조물 하단 길이**다.
|
||||
부르는 쪽이 그 폭과 까닭(`width_basis`)을 함께 넘긴다.
|
||||
"""
|
||||
if not wants_blinding(options) or base_width_m <= 0 or length_m <= 0:
|
||||
return None
|
||||
basis = (
|
||||
f"기초 폭 {base_width_m:.2f}m × 연장 × 두께 {BLINDING_THICKNESS_M:.2f}m"
|
||||
" · 두께는 KDS 44 90 00(100㎜) · 폭은 잡석다짐 폭(KCS 34 50 05)"
|
||||
)
|
||||
if width_basis:
|
||||
basis = f"{basis} = {width_basis}"
|
||||
return Component(
|
||||
"버림콘크리트",
|
||||
"㎥",
|
||||
base_width_m * length_m * BLINDING_THICKNESS_M,
|
||||
DESTINATION["버림콘크리트"],
|
||||
basis,
|
||||
)
|
||||
|
||||
|
||||
#: 큰돌쌓기(품셈 13-6) 직경 갈래 — **저장 제원 `stone_cm` 과 글자까지 같다**(레지스트리 확인).
|
||||
#: ⚠ 돌쌓기(13-4)의 **뒷길이** 축과 섞지 않는다. 앞서 섞여 있어 직경 60~80 짜리가
|
||||
#: 「뒷길이 45㎝」 계수로 돌던 자리다.
|
||||
BOULDER_DIAMETERS = ("40~60", "60~80", "80~100")
|
||||
|
||||
#: 큰돌쌓기 전개 상수. **재료 원단위는 품셈에 없다** — 13-6 [주]⑦ 「재료량은 설계수량을 적용한다」.
|
||||
#: 그래서 여기서 내는 것은 **면적과 터파기 계열까지**이고 큰돌 자체는 미확보로 둔다.
|
||||
BOULDER_MASONRY = {
|
||||
# ⚠ 검산 참고값 — 곱하지 않는다. 까닭은 `STONE_MASONRY` 의 같은 칸 주석을 볼 것.
|
||||
# 큰돌쌓기는 전면 기울기가 「1:0.3 **이상**」이라 특히 하드코딩하면 안 된다.
|
||||
"sheet_check_factor_at_0_3": 1.04,
|
||||
# ⚠ 터파기 여유폭·되메우기 두께는 **공용 한 벌**로 옮겼다
|
||||
# (`common_util/common_util_excavation.py` — 화면 B06 과 짝인 TS 가 있다).
|
||||
# 여기서 다시 두면 그림과 물량이 갈린다.
|
||||
}
|
||||
|
||||
|
||||
def _boulder_diameter(options: dict[str, Any]) -> str | None:
|
||||
"""저장 제원의 직경 갈래. 표에 없는 값이면 **지어내지 않고 `None`**."""
|
||||
raw = options.get("stone_cm")
|
||||
text = str(raw).strip() if raw is not None else ""
|
||||
return text if text in BOULDER_DIAMETERS else None
|
||||
|
||||
|
||||
def boulder_masonry(
|
||||
height_m: float, length_m: float, options: dict[str, Any]
|
||||
) -> tuple[list[Component], list[str]]:
|
||||
"""큰돌쌓기(품셈 13-6) 전개 — **치수로 낼 수 있는 것까지만** 낸다.
|
||||
|
||||
⚠ 재료 원단위가 품셈에 없다 — 13-6 [주]⑦ 「재료량은 설계수량을 적용한다」.
|
||||
돌쌓기(13-4)처럼 「뒷길이별 돌중량·고임돌」 표가 **없으므로** 큰돌·고임돌·막자갈을
|
||||
내지 않는다. 대신 **왜 못 내는지**를 알림으로 남긴다.
|
||||
|
||||
⚠ 고임돌·채움콘크리트 품은 **품에 포함**돼 있다(13-6-1 [주]①·13-6-2 [주]①) —
|
||||
따로 세우면 이중계상이다.
|
||||
|
||||
⚠ 뒤채움(조약돌)은 **13-3 을 적용**한다(13-6 [주]⑧) — 이 표에서 찾지 않는다.
|
||||
두께는 13-4-3 [주]⑨ 를 준용하는데 **직고별 범위값**(상부 20~40㎝ / 하부 30~140㎝)이라
|
||||
한 값으로 못 정한다. 미확보로 둔다.
|
||||
"""
|
||||
notes: list[str] = []
|
||||
if height_m <= 0 or length_m <= 0:
|
||||
return [], ["높이·연장이 없어 전개하지 않음"]
|
||||
|
||||
diameter = _boulder_diameter(options)
|
||||
if diameter is None:
|
||||
return [], [
|
||||
"큰돌쌓기 돌 직경이 아직 입력되지 않았습니다 — 구조물 상세 입력에서 "
|
||||
f"{' · '.join(BOULDER_DIAMETERS)}㎝ 중 하나를 고르면 값이 섭니다"
|
||||
]
|
||||
|
||||
# ⚠ 큰돌쌓기는 **표준경사표 대상이 아니다** — 교본이 「1:0.3 **이상**」으로만 두었다.
|
||||
# 저장 제원에 값이 있으면 그것을 쓰고, 없으면 하한 0.3 으로 둔다.
|
||||
slope_ratio = _num(options.get("face_slope_ratio"), 0.3)
|
||||
face_area = height_m * length_m
|
||||
# 기울기 몫은 **한 번만** — 13-4 와 같은 자리다(㉘).
|
||||
masonry_area = face_area * math.hypot(1.0, slope_ratio)
|
||||
|
||||
components = [
|
||||
Component(
|
||||
"큰돌쌓기",
|
||||
"㎡",
|
||||
masonry_area,
|
||||
DESTINATION["돌쌓기"],
|
||||
f"정면적 × √(1+{slope_ratio}²) · 직경 {diameter}㎝ (품셈 13-6)",
|
||||
)
|
||||
]
|
||||
|
||||
# 터파기·되메우기·잔토 — 치수에서 나온다. 두께는 **직경 갈래의 위 끝**을 벽 두께로 본다.
|
||||
# ⚠ 품셈에 큰돌쌓기 터파기 폭 규정이 없어 **돌쌓기와 같은 방식**(벽 두께 + 여유 0.2m)으로
|
||||
# 낸다. 근거 문구에 그 사실을 적어 되짚을 수 있게 한다.
|
||||
upper_cm = float(str(diameter).split("~")[-1])
|
||||
thickness = upper_cm / 100.0
|
||||
# 버림 콘크리트 — 돌쌓기와 같은 자리(사용자 확정 ⑭). 빼려면 저장 제원에서 「안 넣음」.
|
||||
blinding = _blinding_component(
|
||||
thickness,
|
||||
length_m,
|
||||
options,
|
||||
f"하단 길이(직경 위끝) {thickness:.2f}m — 확정 ⑪ 준용",
|
||||
)
|
||||
if blinding is not None:
|
||||
components.append(blinding)
|
||||
components.extend(
|
||||
_trench_components(
|
||||
height_m,
|
||||
length_m,
|
||||
thickness,
|
||||
options,
|
||||
notes,
|
||||
"⚠ 큰돌쌓기 터파기 폭 규정이 품셈에 없어 돌쌓기 방식을 준용",
|
||||
)
|
||||
)
|
||||
|
||||
notes.append(
|
||||
"재료(큰돌) 원단위 미확보 — 품셈 13-6 [주]⑦ 「재료량은 설계수량을 적용한다」라 "
|
||||
"돌쌓기(13-4)의 뒷길이별 돌중량 표에 해당하는 것이 없음"
|
||||
)
|
||||
notes.append("고임돌·채움콘크리트는 **품에 포함**(13-6 [주]①) — 따로 세우지 않음")
|
||||
notes.append(
|
||||
"뒤채움(조약돌)은 13-3 적용(13-6 [주]⑧). 두께는 13-4-3 [주]⑨ 준용인데 "
|
||||
"직고별 범위값(상부 20~40㎝ / 하부 30~140㎝)이라 한 값으로 못 정함 — 미확보"
|
||||
)
|
||||
# ⚠ **채워져 있으면 그 사유를 붙이지 않는다**(2026-09-09). `bond` 는 레지스트리에 이미
|
||||
# 있는 칸인데 조건 없이 붙이고 있어, 값을 고른 뒤에도 「못 고름」이 뜨고 있었다.
|
||||
# ⚠ 얻는 것은 **노무 품 갈래뿐**이고 재료 원단위는 13-6 [주]⑦ 때문에 여전히 안 선다 —
|
||||
# 그래도 고치는 까닭은 **사유가 거짓이면 사유 칸 전체를 못 믿게 되기 때문**이다.
|
||||
if not str(options.get("bond") or "").strip():
|
||||
notes.append("⚠ 메/찰 구분이 저장 제원에 없어 13-6-1(메)·13-6-2(찰) 중 어느 쪽인지 못 고름")
|
||||
return components, notes
|
||||
|
||||
|
||||
def _trench_components(
|
||||
height_m: float,
|
||||
length_m: float,
|
||||
thickness: float,
|
||||
options: dict[str, Any],
|
||||
notes: list[str],
|
||||
extra_basis: str = "",
|
||||
) -> list[Component]:
|
||||
"""터파기·되메우기·잔토 — **공용 단면 함수 한 벌**(`common_util_excavation`).
|
||||
|
||||
⚠ 여기서 식을 다시 짜지 않는다 — 화면(B06 횡단도)이 그리는 단면과 같은 코드라야
|
||||
그림과 물량이 안 갈린다. 평균두께는 이쪽이 낸 값을 **넘겨 준다**(저쪽은 두께를 모름).
|
||||
⚠ 「기초유 / 기초버림」은 정본 탭 제목이고 **기초분 0.45 냐 0.07 이냐**를 가른다.
|
||||
안 고른 프로젝트는 **비탈분만** 세우고 사유를 남긴다 — 한쪽으로 찍으면 임의값이
|
||||
금액으로 굳는다(기초분이 통째로 0.45㎥/m 다).
|
||||
"""
|
||||
foundation = str(options.get("foundation") or "").strip()
|
||||
if foundation in ("기초유", "기초버림"):
|
||||
has_foundation = foundation == "기초유"
|
||||
area = wall_trench_area_m2(height_m, thickness, has_foundation=has_foundation)
|
||||
base_depth = WALL_FOUNDATION_DEPTH_M if has_foundation else WALL_BLINDING_DEPTH_M
|
||||
base_basis = f"기초분({foundation}) + "
|
||||
else:
|
||||
width = wall_trench_width_m(thickness)
|
||||
area = None if width is None else height_m * width
|
||||
base_depth = 0.0
|
||||
base_basis = ""
|
||||
notes.append(
|
||||
"⚠ 기초 유/무(기초유·기초버림)를 안 정해 **기초 터파기 몫이 안 섰음** — 비탈분만 세움"
|
||||
)
|
||||
if area is None:
|
||||
notes.append("⚠ 높이·평균두께가 없어 터파기가 서지 않음")
|
||||
return []
|
||||
excavation = area * length_m
|
||||
backfill = (base_depth + height_m) * WALL_TRENCH_CLEARANCE_M * length_m
|
||||
basis = (
|
||||
f"{base_basis}H × (평균두께 {thickness:.2f} + {WALL_TRENCH_CLEARANCE_M}) × 연장"
|
||||
f" · {BASIS_WALL}"
|
||||
)
|
||||
if extra_basis:
|
||||
basis = f"{basis} · {extra_basis}"
|
||||
return [
|
||||
Component("터파기", "㎥", excavation, DESTINATION["터파기"], basis),
|
||||
Component(
|
||||
"되메우기",
|
||||
"㎥",
|
||||
backfill,
|
||||
DESTINATION["되메우기"],
|
||||
f"(기초깊이 {base_depth:g} + H) × {WALL_TRENCH_CLEARANCE_M} × 연장",
|
||||
),
|
||||
Component(
|
||||
"잔토처리",
|
||||
"㎥",
|
||||
excavation - backfill,
|
||||
DESTINATION["잔토처리"],
|
||||
"터파기 − 되메우기",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def stone_masonry(
|
||||
height_m: float,
|
||||
length_m: float,
|
||||
options: dict[str, Any],
|
||||
wet: bool,
|
||||
face: str | None = None,
|
||||
face_reason: str = "",
|
||||
) -> tuple[list[Component], list[str]]:
|
||||
"""돌쌓기(찰/메) 1구간 전개 — 실무 `기슭막이(찰쌓기, H=1.5, 기초무)` 시트의 식.
|
||||
|
||||
실측 대조(m당, H=1.5, 뒷길이 45㎝, 기울기 1:0.3):
|
||||
정면적 1.5 · 비탈면적 1.57 · 평균두께 0.83 · 입적 1.245
|
||||
터파기 1.55 · 되메우기 0.30 · 잔토 1.25
|
||||
"""
|
||||
notes: list[str] = []
|
||||
if height_m <= 0 or length_m <= 0:
|
||||
return [], ["높이·연장이 없어 전개하지 않음"]
|
||||
|
||||
back_cm = _back_length(options, wet=wet, height_m=height_m)
|
||||
if back_cm not in STONE_BACK_LENGTH_TABLE:
|
||||
# ⚠ 접지 않는다 — 다른 규격 계수가 조용히 도는 것보다 「없다」가 낫다.
|
||||
return [], [
|
||||
f"뒷길이 {back_cm}㎝ 는 품셈 표(25·30·35·45·55·60·75㎝)에 없어 물량이 서지 않습니다"
|
||||
]
|
||||
table = dict(STONE_BACK_LENGTH_TABLE[back_cm])
|
||||
default_note = back_length_default_note(options, back_cm, wet=wet, height_m=height_m)
|
||||
if default_note:
|
||||
notes.append(default_note)
|
||||
height_note = None if wet else dry_masonry_height_note(height_m)
|
||||
if height_note:
|
||||
notes.append(height_note)
|
||||
# ⚠ **돌 종류로 계수가 갈린다** (품셈 13-4-3 · 13-4-4 [주]① · 교본 7-3).
|
||||
# 안 고르면 종전 값(건설품셈 참고자료)으로 돌되 그 사실을 알린다 — 값을 못 낸다고
|
||||
# 멈추면 이미 저장된 프로젝트가 통째로 빈다.
|
||||
picked, kind_note = stone_coefficients(options, back_cm)
|
||||
if kind_note:
|
||||
notes.append(kind_note)
|
||||
# ⚠ **고른 종류의 빈 칸은 빈 칸으로 덮는다.** `None` 이라고 안 덮으면 종전 값(깬돌 계열)이
|
||||
# 남아 「야면석 75㎝」처럼 **원문에 「-」인 칸에 값이 서는** 일이 생긴다(만들다 잡음).
|
||||
if picked.get("kind"):
|
||||
for key in ("wedge_stone_m3_per_m2", "fill_concrete_m3_per_m2"):
|
||||
table[key] = picked.get(key)
|
||||
else:
|
||||
for key in ("wedge_stone_m3_per_m2", "fill_concrete_m3_per_m2"):
|
||||
if picked.get(key) is not None:
|
||||
table[key] = picked[key]
|
||||
# ⚠ **교본의 「뒤채움 몫」(깬돌·잡석 1/2 · 야면석 1/3)은 이제 막자갈 밑수가 아니다** —
|
||||
# ⭐ 확정 5차 작은 것 3 이 막자갈을 **뒷채움 폭 사다리꼴**로 못 박았다. 표는 그대로
|
||||
# 두되(다른 자리에서 쓸 수 있다) 여기서는 안 쓴다.
|
||||
kind_label = str(picked.get("kind") or "")
|
||||
# ⭐ 2026-09-13 브레인 판정 — **계수 열 고르기와 돌종류는 다른 축.** 「실무 관행」은 계수 열만
|
||||
# 바꾸고 종류는 지워선 안 됨(야면석이 「돌」·계산식 무게로 조용히 서던 결함). 돌 규격·무게는
|
||||
# **저장 제원의 종류**를 따름 — 아는 종류일 때만(모르는 글은 종전대로 규격 빈칸).
|
||||
chosen_kind = str(options.get(STONE_KIND_OPTION) or "").strip()
|
||||
stone_kind_name = (
|
||||
chosen_kind if chosen_kind in (load_stone_kind_table().get("kinds") or []) else kind_label
|
||||
)
|
||||
# ⚠ `face_slope_ratio` 는 **2026-09-09 에 칸이 생겼다**(돌쌓기 계열 여섯 종류 · 표준도
|
||||
# 제원 폼). 빈 값이 「자동」의 뜻이라 비어 있으면 아래 표준경사표가 돌고, 채우면 그 값이
|
||||
# 이긴다. (키 이름 어긋남으로 저장값이 안 닿던 `back_len_cm` 사고와 구별할 것.)
|
||||
# **기본 0.3 의 근거** — 교본 7-3 돌흙막이 기준: 「돌 찰쌓기 3.0m 이하 **1:0.3** /
|
||||
# 돌 메쌓기 2.0m 이하 **1:0.3** / 큰돌쌓기 **1:0.3 이상**(전도 방지)」
|
||||
# (`지식DB 02_상세설계/구조물/돌쌓기.md §1`, 값은 `data_masonry` 의 `face_slope`).
|
||||
# 전면 기울기 — 품셈 표준경사표로 자동 판정하고, 저장 제원에 값이 있으면 그것이 이긴다.
|
||||
slope_ratio, slope_basis = face_slope_ratio(
|
||||
options, wet=wet, height_m=height_m, face=face, face_reason=face_reason
|
||||
)
|
||||
constants = STONE_MASONRY
|
||||
|
||||
face_area = height_m * length_m # 정면적
|
||||
# 돌쌓기 면적 = 정면적 × √(1+n²) — 기울어진 만큼 길어진다. **기울기 몫은 한 번만.**
|
||||
# 실무 시트의 「정면적 × 1.04」가 바로 이 값이다(1:0.3 에서 1.0440 ≈ 1.04).
|
||||
masonry_area = face_area * math.hypot(1.0, slope_ratio)
|
||||
# 벽 두께 — 실무 구조물도 식(확정 2차 ②). **뒷길이가 들어간다.**
|
||||
# ⚠ 표준도 제원에 상부·하부 두께 칸이 있으면 **그 값이 식을 이긴다**(확정 ② 후반).
|
||||
back_m = back_cm / 100.0 # 석적(아래)이 뒷길이를 m 로 쓴다
|
||||
top_thickness, bottom_thickness, thickness_basis = wall_thickness(
|
||||
options, back_cm=back_cm, height_m=height_m
|
||||
)
|
||||
thickness = (top_thickness + bottom_thickness) / 2.0
|
||||
volume = face_area * thickness # 입적
|
||||
|
||||
components = [
|
||||
Component(
|
||||
"돌쌓기",
|
||||
"㎡",
|
||||
masonry_area,
|
||||
DESTINATION["돌쌓기"],
|
||||
f"정면적 × √(1+{slope_ratio:g}²) — 비탈면적 · {slope_basis}",
|
||||
),
|
||||
# 입적 — 정본 계산표 좌측 열의 이름 그대로다(확정 ⑦). 값을 새로 내는 것이 아니라
|
||||
# 이미 쓰고 있던 밑수를 화면에 드러내는 줄이다.
|
||||
Component(
|
||||
"입적",
|
||||
"㎥",
|
||||
volume,
|
||||
DESTINATION["입적"],
|
||||
f"정면적 × 평균두께 {thickness:.2f} · {thickness_basis}",
|
||||
),
|
||||
]
|
||||
# ⚠ 고임돌 계수가 **원문에서 「-」**인 칸이 있다(견치돌 25·30 · 야면석 75 · 깬돌 25).
|
||||
# 그 규격에 그 돌을 안 쓴다는 뜻이라 **0 줄을 만들지 않는다** — 0 은 「없음」과
|
||||
# 구별이 안 되고, 받는 쪽이 「값이 0 인 자재」로 읽는다.
|
||||
if table["wedge_stone_m3_per_m2"] is None:
|
||||
notes.append(
|
||||
f"고임돌 계수가 품셈 표에 없습니다 — 뒷길이 {back_cm}㎝"
|
||||
+ (f" · {kind_label}" if kind_label else "")
|
||||
+ " 칸이 「-」입니다"
|
||||
)
|
||||
else:
|
||||
components.append(
|
||||
Component(
|
||||
"고임돌",
|
||||
"㎥",
|
||||
masonry_area * _num(table["wedge_stone_m3_per_m2"]),
|
||||
DESTINATION["고임돌"],
|
||||
f"돌쌓기 × {table['wedge_stone_m3_per_m2']} ㎥/㎡ (뒷길이 {back_cm}㎝)"
|
||||
+ (f" · {kind_label}" if kind_label else ""),
|
||||
)
|
||||
)
|
||||
|
||||
# 석적 — ⭐ 확정 5차 작은 것 4. 「체적」은 위 **입적** 줄이 이미 그것이고, 이쪽만 새 줄이다.
|
||||
stone_pile = face_area * back_m * STONE_PACKING_RATIO
|
||||
components.append(
|
||||
Component(
|
||||
"석적",
|
||||
"㎥",
|
||||
stone_pile,
|
||||
DESTINATION["석적"],
|
||||
f"정면적 × 뒷길이 {back_m:g}m × {STONE_PACKING_RATIO}(채움률) · {STONE_PILE_BASIS}",
|
||||
)
|
||||
)
|
||||
|
||||
# 돌 무게 — ⭐ 확정 5차 큰 것 7. **계산식이 기본**이고 야면석만 관측표다(헬퍼 한 벌).
|
||||
# ⚠ 안 고른 경우도 **계산식**으로 선다 — 정본 여섯 탭이 종류를 안 적고 그 식을 쓰고,
|
||||
# 그래야 정본 H=2.0 의 「1.92 톤」과 맞는다(관측표 0.88 로 서면 1.84 로 4 % 낮다).
|
||||
stone_name, stone_ton, weight_tail, weight_source = stone_weight_per_m2(
|
||||
back_cm, stone_kind_name, table["stone_ton_per_m2"]
|
||||
)
|
||||
weight_basis = f"돌쌓기 {weight_tail}" if weight_tail else ""
|
||||
if not stone_kind_name:
|
||||
notes.append(
|
||||
"돌 종류를 안 골라 **계산식**(뒷길이 × 0.77 × 2.65)으로 섰습니다 — "
|
||||
"야면석이면 계산식이 안 맞아 관측표로 갈립니다"
|
||||
)
|
||||
if stone_ton is None:
|
||||
# 원본 표가 비어 있는 칸이다 — 지어내지 않고 알린다(PLAN 8-8 ㉮).
|
||||
notes.append(f"뒷길이 {back_cm}㎝ 의 돌중량이 원본 표에 없어 {stone_name}을 내지 못함")
|
||||
else:
|
||||
components.append(
|
||||
Component(
|
||||
"돌",
|
||||
"ton",
|
||||
masonry_area * stone_ton,
|
||||
DESTINATION["돌"],
|
||||
weight_basis,
|
||||
source=weight_source,
|
||||
spec=stone_kind_name,
|
||||
)
|
||||
)
|
||||
|
||||
# 막자갈(뒷채움) — ⭐ 확정 5차 작은 것 3 「(상 + 하) ÷ 2 × H」. **뒷채움 폭**이지 벽 두께가
|
||||
# 아니다. 옛 식(입적 − 몸통 − 고임돌)에서 갈아탄 자리다.
|
||||
backfill_top = STONE_BACKFILL_WIDTH_M["top"]
|
||||
backfill_bottom = STONE_BACKFILL_WIDTH_M["bottom"]
|
||||
rubble = (backfill_top + backfill_bottom) / 2.0 * height_m * length_m
|
||||
components.append(
|
||||
Component(
|
||||
"막자갈",
|
||||
"㎥",
|
||||
rubble,
|
||||
DESTINATION["막자갈"],
|
||||
f"(뒷채움 상 {backfill_top:g} + 하 {backfill_bottom:g}) ÷ 2 × H {height_m:g}m"
|
||||
f" × 연장 {length_m:g}m · 정본 여섯 탭 공통값",
|
||||
)
|
||||
)
|
||||
notes.append(
|
||||
f"막자갈 뒷채움 폭이 정본(`04.구조도(기슭막이).xls`) 값 상 {backfill_top:g} · "
|
||||
f"하 {backfill_bottom:g}m 붙박이입니다 — 구조물 제원에 뒷채움 폭 칸이 없습니다"
|
||||
"(소광리는 같은 식에 0.30/0.60 을 씁니다) · 칸을 새로 두는 것은 B05 등록부 몫"
|
||||
)
|
||||
# ⚠ 근거 구간 밖은 **값이 서되 그 사실이 보여야 한다** — 값은 계속 나오므로 사유가
|
||||
# 없으면 아무도 못 본다(2026-09-09 그물 침).
|
||||
if height_m > STONE_BACKFILL_VERIFIED_HEIGHT_M:
|
||||
notes.append(
|
||||
f"직고 {height_m:g}m 는 뒷채움 폭 정본 확인 구간"
|
||||
f"(H {STONE_BACKFILL_VERIFIED_HEIGHT_M:g}m 이하) **밖**입니다 — 붙박이 폭이라 "
|
||||
"직고가 커져도 두께가 안 늘어 품셈 13-4-4 [주]⑨ 뒤채움 조약돌 두께 범위보다 "
|
||||
"얇아집니다(그 표는 직고와 함께 두꺼워짐). 값은 정본 식대로 세우되 "
|
||||
"**높은 벽의 뒷채움 폭은 실무자 확인이 필요합니다**"
|
||||
)
|
||||
|
||||
if wet:
|
||||
mpa, mpa_basis = fill_concrete_mpa(options)
|
||||
components.append(
|
||||
Component(
|
||||
"채움콘크리트",
|
||||
"㎥",
|
||||
masonry_area * _num(table["fill_concrete_m3_per_m2"]),
|
||||
DESTINATION["채움콘크리트"],
|
||||
# ⚠ 돌 종류로 계수가 갈리는데(깬돌 0.2 · 야면석 0.15) 근거에 종류가 안
|
||||
# 적히던 자리다 — 값은 바뀌는데 **왜 바뀌었는지가 안 보였다**(고임돌은
|
||||
# 적고 있었다). 2026-09-09 맞춤.
|
||||
f"돌쌓기 × {table['fill_concrete_m3_per_m2']} ㎥/㎡ (뒷길이 {back_cm}㎝)"
|
||||
+ (f" · {kind_label}" if kind_label else "")
|
||||
+ f" · {mpa_basis}",
|
||||
spec=f"{mpa}",
|
||||
)
|
||||
)
|
||||
components.append(
|
||||
Component(
|
||||
"모르터",
|
||||
"㎥",
|
||||
masonry_area * constants["mortar_m3_per_m2"],
|
||||
DESTINATION["모르터"],
|
||||
f"돌쌓기 × {constants['mortar_m3_per_m2']} ㎥/㎡ (줄눈)",
|
||||
)
|
||||
)
|
||||
# ⚠ 여기서 멈춘다 — 모르터를 시멘트·모래로 쪼개지 않는다(㉢).
|
||||
|
||||
# 물구멍 — 벽면적 2㎡당 1개소, 개소당 관 길이 = **평균두께**(관이 벽을 가로지름).
|
||||
# ⚠ 이것은 **관(파이프) 자재**이지 공제 대상이 아니다. 품셈 1-2-1 이 「공제하지 않는다」고
|
||||
# 말하는 물구멍은 **콘크리트 체적에서 뺄 구멍**이고, 여기 값은 그 구멍에 넣는 **관 길이**다.
|
||||
# ⚠⚠ **㉥ 이중계상** — 품셈 13-6-2·13-7-2 [주]③ 은 제잡비 **윗단** 값에
|
||||
# 「물빼기 파이프 설치에 관계되는 노무비, 재료비를 포함한다」고 한다. 그 쪽을 쓰면
|
||||
# 이 줄과 겹친다. **우리 선택은 이 줄을 세우고 제잡비는 아랫단(미설치)** 이다
|
||||
# (`structure_unit_observed` 의 `double_count_rules`).
|
||||
# 지금 쓰는 13-4 계열에는 제잡비 행 자체가 없어 겹치지 않는다(전수 확인).
|
||||
# ⚠ 관종·지름은 미확정 — 법은 「지름 3~6㎝ 파이프」, 실무 관측은 Ø50. 규격이 정해지면
|
||||
# 이름에 붙인다(`물구멍 Ø50`). 지어내지 않고 규격 없는 이름으로 둔다.
|
||||
# ⚠ 「미확정」만 적으면 사용자가 무엇을 정해야 하는지 모른다 —
|
||||
# **지금 무슨 값으로 돌고 있는지**를 함께 적는다(원단위 미확보와 같은 방식).
|
||||
# ⭐ 2026-09-13 브레인 판정(PLAN 10장) — 개소당 관 길이 = **평균두께**. 정본이 그렇게 잡고
|
||||
# 관은 벽을 가로지르므로 이치에도 맞음. 옛 상수 0.5m 는 그만큼 짧게 섰음.
|
||||
# ⚠ 골막이(`_Revetment`)는 그 정본이 0.5 라 그대로 둠 — 여기는 기슭막이 계열만.
|
||||
hole_area, hole_dia, hole_basis = weep_hole_spec(options)
|
||||
components.append(
|
||||
Component(
|
||||
"물구멍관",
|
||||
"m",
|
||||
masonry_area / hole_area * thickness,
|
||||
DESTINATION["물구멍관"],
|
||||
f"돌쌓기 ÷ {hole_area:g}㎡/개소 × 평균두께 {thickness:.2f} m/개소 · {hole_basis}",
|
||||
spec=f"Ø{hole_dia}",
|
||||
)
|
||||
)
|
||||
|
||||
# 채집석 — **캐서 쓰는 구조물**의 돌 체적. 사토에서 뺄 밑수이고 **여기서 빼지 않는다.**
|
||||
# ⚠ 밑수가 확정 5차로 바뀌었다 — 면석 몸통은 이제 **석적**(정면적×뒷길이×0.77)이고
|
||||
# 막자갈은 **뒷채움 사다리꼴**이다. 옛 「입적 − 몸통 − 고임돌」 몫이 아니다.
|
||||
if all(options.get(key) in (None, "") for key in STONE_SUPPLY_KEYS):
|
||||
# 폼이 기본값을 값으로 안 보내게 된 뒤(2026-09-14) 빈 조달이 조용히 「채집」이 되지 않게.
|
||||
notes.append(STONE_SUPPLY_DEFAULT_NOTE)
|
||||
if is_collected_stone(options):
|
||||
wedge = masonry_area * _num(table["wedge_stone_m3_per_m2"]) # None 이면 0
|
||||
collected = max(stone_pile + wedge + rubble, 0.0)
|
||||
if collected > 0:
|
||||
components.append(
|
||||
Component(
|
||||
"채집석",
|
||||
"㎥",
|
||||
collected,
|
||||
DESTINATION["채집석"],
|
||||
f"석적 {stone_pile:.3f} + 고임돌 {wedge:.3f} + 막자갈 {rubble:.3f} ㎥"
|
||||
" · 현장 채집분 · ⚠ 여기서 빼지 않음 — 사토에서 한 번만 뺌",
|
||||
)
|
||||
)
|
||||
|
||||
# 버림 콘크리트 — 기초 바닥에 까는 얇은 층. 빼려면 저장 제원에서 「안 넣음」으로 둔다.
|
||||
# 잡석다짐 폭 = **하단 길이**(확정 ⑪) — 벽에서는 하부 두께다. 버림도 같은 폭을 쓴다.
|
||||
base_width = bottom_thickness
|
||||
blinding = _blinding_component(
|
||||
base_width,
|
||||
length_m,
|
||||
options,
|
||||
f"하단 길이(하부 두께) {bottom_thickness:.2f}m — 확정 ⑪ 「기준 구조물에서 유도」",
|
||||
)
|
||||
if blinding is not None:
|
||||
components.append(blinding)
|
||||
# ⓘ 기초잡석은 `build_table` 이 버림 뒤에 세움(확정 3차 ② 두께 0.2 · PLAN 10장 「계상함」).
|
||||
# 옛 「두께가 없어 안 섬」 사유는 걷음 — 줄과 사유가 함께 떠 서로 어긋났음(2026-09-13 화면 실측).
|
||||
|
||||
# 터파기·되메우기·잔토 — 토공으로 합산되는 값이다(내역 줄이 아니다).
|
||||
components.extend(
|
||||
_trench_components(height_m, length_m, thickness, options, notes, thickness_basis)
|
||||
)
|
||||
return components, notes
|
||||
@@ -12,9 +12,9 @@
|
||||
⚠ **형태가 돌쌓기가 아닌 갈래는 식이 없다** — 콘크리트·돌망태·통나무/목재틀·바자.
|
||||
지어내지 않고 **왜 없는지**를 줄로 남긴다.
|
||||
|
||||
⚠ **뒷길이·돌 종류 칸이 기슭막이 등록부에 아직 없다**(2026-09-09 실측). 돌쌓기 식은 그 둘로
|
||||
계수가 갈리는데, 없으면 `_back_length` 가 **조용히 45㎝ 로 돈다**. 값이 나오므로 아무도
|
||||
못 알아채는 자리라 **그 사실을 줄로 드러낸다** — 「값이 있기는 하니 안 보이는」 그것.
|
||||
ⓘ 뒷길이·돌 종류 칸은 기슭막이 등록부에 섰음(`test_revetment_stone_fields`). 안 고른 장은
|
||||
돌쌓기 식이 내는 한 줄(「뒷길이를 안 골라 기본 45㎝」·「돌 종류를 안 정해」)이 그대로 드러냄.
|
||||
2026-09-14 까지 붙이던 「칸이 없어」 줄은 **거짓 + 같은 말 두 번**이라 걷음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -23,13 +23,13 @@ import math
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from typing import Any
|
||||
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureSheet import REVETMENT_STONE_FORMS
|
||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import Component, _num, stone_masonry
|
||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Base import routed
|
||||
|
||||
#: 형태 → 돌쌓기 갈래(찰이면 True). 여기 없는 형태는 전개식이 없다.
|
||||
STONE_FORMS: dict[str, bool] = {
|
||||
"돌쌓기(찰)": True,
|
||||
"돌쌓기(메)": False,
|
||||
}
|
||||
#: ⚠ 구조물도 기울기·그림이 같은 표를 씀(`StructureSheet.REVETMENT_STONE_FORMS` 한 벌).
|
||||
STONE_FORMS: dict[str, bool] = REVETMENT_STONE_FORMS
|
||||
|
||||
#: 식이 없는 형태와 **왜 없는지**. 품셈 장이 다르거나 원단위가 원문에 없다.
|
||||
WITHHELD_FORMS: dict[str, str] = {
|
||||
@@ -85,12 +85,6 @@ BED_SILL_FORMS: dict[str, dict[str, Any]] = {
|
||||
},
|
||||
}
|
||||
|
||||
#: 돌쌓기 식이 계수를 가르는 데 쓰는 칸 — 기슭막이 등록부에 아직 없는 것을 알린다.
|
||||
STONE_SPEC_KEYS: tuple[tuple[str, str], ...] = (
|
||||
("back_len_cm", "뒷길이"),
|
||||
("stone_kind", "돌 종류"),
|
||||
)
|
||||
|
||||
|
||||
def revetment(
|
||||
height_m: float,
|
||||
@@ -110,17 +104,7 @@ def revetment(
|
||||
"— 형태를 골라야 물량이 섭니다"
|
||||
]
|
||||
|
||||
components, notes = stone_masonry(
|
||||
height_m, length_m, options, STONE_FORMS[form], face, face_reason
|
||||
)
|
||||
# ⚠ 계수를 가르는 칸이 없으면 **기본값으로 조용히 돈다** — 그 사실을 드러낸다.
|
||||
missing = [label for key, label in STONE_SPEC_KEYS if not options.get(key)]
|
||||
if missing and components:
|
||||
notes.append(
|
||||
f"⚠ 기슭막이 제원에 {' · '.join(missing)} 칸이 없어 **돌쌓기 기본값으로 섰음** "
|
||||
"— 그 값이 고임돌·야면석·채움콘크리트 계수를 가름"
|
||||
)
|
||||
return components, notes
|
||||
return stone_masonry(height_m, length_m, options, STONE_FORMS[form], face, face_reason)
|
||||
|
||||
|
||||
#: 돌골막이 원단위 — 소광리 정본 「골막이(찰)(치수조서연결)」, **개소당**.
|
||||
@@ -201,6 +185,43 @@ def _ceil2(value: float) -> float:
|
||||
return math.ceil(value * 100) / 100
|
||||
|
||||
|
||||
def erosion_check_section(height_m: float, options: dict[str, Any]) -> dict[str, Any]:
|
||||
"""골막이 한 개소의 치수 한 벌 — **표(`erosion_check_dam`)와 구조물도 그림이 같이 씀.**
|
||||
|
||||
상장·하장·높이 · 뒷길이 · 반수면 비탈 · 상부/하부/평균 두께 · 사면장 · 바닥파기 · 방수로 관.
|
||||
⚠ 자름(`_floor2`)도 표와 같게 — 그림 글자가 표 근거 글자와 같은 수를 보이게.
|
||||
"""
|
||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import _back_length
|
||||
|
||||
const = EROSION_CHECK_DAM
|
||||
top_m = _num(options.get("top_length_m"), 0.0)
|
||||
bottom_m = _num(options.get("bottom_length_m"), 0.0)
|
||||
back_cm = _back_length(options)
|
||||
back_m = back_cm / 100.0
|
||||
given = _num(options.get("face_slope_ratio"), 0.0)
|
||||
slope = given if given > 0 else const["default_slope_ratio"]
|
||||
top_raw = back_m + const["thickness_top_per_m"] * height_m
|
||||
bottom_raw = back_m + const["thickness_bottom_per_m"] * height_m
|
||||
thickness = _floor2((top_raw + bottom_raw) / 2)
|
||||
return {
|
||||
"top_m": top_m,
|
||||
"bottom_m": bottom_m,
|
||||
"height_m": height_m,
|
||||
"back_cm": back_cm,
|
||||
"back_m": back_m,
|
||||
"slope": slope,
|
||||
"slope_given": given > 0,
|
||||
"top_t": _floor2(top_raw),
|
||||
"bottom_t": bottom_raw,
|
||||
"thickness": thickness,
|
||||
"slant": _floor2(math.hypot((top_m - bottom_m) / 2, height_m)),
|
||||
"trench_width": thickness + const["trench_extra_m"],
|
||||
"trench_depth": const["trench_depth_m"],
|
||||
"spillway": str(options.get("spillway") or "").strip() != "없음",
|
||||
"pipe_r": const["spillway_pipe_r_m"],
|
||||
}
|
||||
|
||||
|
||||
def erosion_check_dam(
|
||||
height_m: float, options: dict[str, Any]
|
||||
) -> tuple[list[Component], list[str]]:
|
||||
@@ -210,6 +231,7 @@ def erosion_check_dam(
|
||||
STONE_BACK_LENGTH_TABLE,
|
||||
STONE_MASONRY,
|
||||
_back_length,
|
||||
back_length_default_note,
|
||||
fill_concrete_mpa,
|
||||
stone_weight_per_m2,
|
||||
weep_hole_spec,
|
||||
@@ -240,19 +262,16 @@ def erosion_check_dam(
|
||||
coeff = STONE_BACK_LENGTH_TABLE.get(back_cm)
|
||||
if coeff is None:
|
||||
return [], [f"뒷길이 {back_cm}㎝ 는 품셈 표(25·30·35·45·55·60·75㎝)에 없어 계수가 없습니다"]
|
||||
if not any(options.get(key) is not None for key in ("back_len_cm", "stone_back_length_cm")):
|
||||
notes.append(
|
||||
f"⚠ 뒷길이를 안 골라 기본 {back_cm}㎝ 로 섰습니다 "
|
||||
"— 그 값이 두께식·고임돌·돌 무게·채움콘크리트 계수를 모두 가릅니다"
|
||||
)
|
||||
back_m = back_cm / 100.0
|
||||
|
||||
given = _num(options.get("face_slope_ratio"), 0.0)
|
||||
if given > 0:
|
||||
slope, slope_note = given, f"사용자 지정 1:{given:g}"
|
||||
else:
|
||||
slope = const["default_slope_ratio"]
|
||||
slope_note = f"1:{slope:g} — 정본 「반수면비탈」 붙박이(안 정함)"
|
||||
default_note = back_length_default_note(options, back_cm)
|
||||
if default_note:
|
||||
notes.append(default_note)
|
||||
geo = erosion_check_section(height_m, options)
|
||||
back_m, slope = geo["back_m"], geo["slope"]
|
||||
slope_note = (
|
||||
f"사용자 지정 1:{slope:g}"
|
||||
if geo["slope_given"]
|
||||
else f"1:{slope:g} — 정본 「반수면비탈」 붙박이(안 정함)"
|
||||
)
|
||||
|
||||
# ① 정면적(사다리꼴) − ② 방수로 파형강관 단면
|
||||
# ⚠ 「없음」이면 안 뺀다 — 정면적이 밑수라 **열한 줄이 통째로 움직인다.**
|
||||
@@ -272,14 +291,7 @@ def erosion_check_dam(
|
||||
)
|
||||
|
||||
masonry = _floor2(front_area * _round2(math.hypot(slope, 1.0)))
|
||||
top_t = _floor2(back_m + const["thickness_top_per_m"] * height_m)
|
||||
thickness = _floor2(
|
||||
(
|
||||
(back_m + const["thickness_top_per_m"] * height_m)
|
||||
+ (back_m + const["thickness_bottom_per_m"] * height_m)
|
||||
)
|
||||
/ 2
|
||||
)
|
||||
top_t, thickness = geo["top_t"], geo["thickness"]
|
||||
volume = _floor2(front_area * thickness)
|
||||
facing = top_m * (top_t - back_m)
|
||||
base_area = masonry + facing
|
||||
@@ -340,9 +352,7 @@ def erosion_check_dam(
|
||||
)
|
||||
|
||||
# 바닥파기 — 비탈 사면장 두 쪽 + 하장 한 쪽, 폭은 평균두께 + 0.3.
|
||||
slant = _floor2(math.hypot((top_m - bottom_m) / 2, height_m))
|
||||
width = thickness + const["trench_extra_m"]
|
||||
depth = const["trench_depth_m"]
|
||||
slant, width, depth = geo["slant"], geo["trench_width"], geo["trench_depth"]
|
||||
trench = _floor2(slant * depth * width * 2 + bottom_m * depth * width)
|
||||
spoil = _round2(trench - (slant * depth * width + bottom_m * depth * width))
|
||||
rows.extend(
|
||||
@@ -360,8 +370,9 @@ def erosion_check_dam(
|
||||
)
|
||||
|
||||
components = [
|
||||
Component(name, unit, amount, DESTINATION.get(name, "quantity"), basis, spec="".join(spec))
|
||||
Component(name, unit, amount, destination, basis, spec="".join(spec))
|
||||
for name, unit, amount, basis, *spec in rows
|
||||
if (destination := routed(name, notes))
|
||||
]
|
||||
mpa, mpa_basis = fill_concrete_mpa(options)
|
||||
fill_per = coeff["fill_concrete_m3_per_m2"]
|
||||
@@ -415,7 +426,9 @@ OPEN_DITCH_FORMS: dict[str, dict[str, Any]] = {
|
||||
("면목", "m", 2.0 * 1.0, "2 × 1"),
|
||||
),
|
||||
},
|
||||
"콘크리트 L형수로 H=0.2": {
|
||||
# ⚠ 키는 **등록부 선택지 글자 그대로**(「L형수로 H=0.2」) — 2026-09-14 까지 앞에
|
||||
# 「콘크리트 」가 붙어 있어 골라도 **영영 「원단위가 없습니다」**로 섰음.
|
||||
"L형수로 H=0.2": {
|
||||
"rows": (
|
||||
("터파기", "㎥", 0.31645, "[{(1.11+1.51)÷2×0.2} + {(0.33×0.33)÷2}] × 1.0"),
|
||||
("되메우기", "㎥", 0.129975, "(0.2+0.30)÷2×0.25×1 + (0.2+0.19)÷2×0.33×1.0"),
|
||||
@@ -426,7 +439,7 @@ OPEN_DITCH_FORMS: dict[str, dict[str, Any]] = {
|
||||
0.1508,
|
||||
"{(0.2+0.15)÷2×0.7} + (0.21×0.13)×1 + {(0.15+0.21)÷2×0.20}×1",
|
||||
),
|
||||
("이형철근 D13", "kg", 0.398, "0.2 × 2 × 0.995"),
|
||||
("이형철근", "kg", 0.398, "0.2 × 2 × 0.995", "D13"), # 이름 · 규격 따로
|
||||
("거푸집", "㎡", 0.738, "0.2 + 0.33 + √(0.2² + 0.06²)"),
|
||||
("면목", "m", 1.0, "1"),
|
||||
),
|
||||
@@ -446,6 +459,17 @@ OPEN_DITCH_FORMS: dict[str, dict[str, Any]] = {
|
||||
#: 수량은 여기서 제 것으로 낸다.** 그림이 같다고 기슭막이 원단위를 끌어오면 그 확정을 어긴다.
|
||||
SOIL_GUARD_SOD: dict[str, Any] = {
|
||||
"unit_label": "개소당",
|
||||
#: 정본 평균 붙박이 치수(m) — 아래 줄 식의 수 그대로. 기슭 0.54 = 사면장 √(0.2²+0.5²).
|
||||
#: 구조물도 그림이 이 치수로 그림(`test_b08_structure_figure_small` 이 줄 값과 대조).
|
||||
"dims": {
|
||||
"top_m": 1.5,
|
||||
"bottom_m": 1.1,
|
||||
"height_m": 0.5,
|
||||
"sod_m": 0.2,
|
||||
"slant_m": 0.54,
|
||||
"trench_width_m": 0.4,
|
||||
"trench_depth_m": 0.2,
|
||||
},
|
||||
"rows": (
|
||||
(
|
||||
"떼",
|
||||
@@ -467,7 +491,6 @@ def soil_guard(options: dict[str, Any]) -> tuple[list[Component], list[str]]:
|
||||
⚠ 치수가 **전부 평균 붙박이**다 — 정본 표에 「(평균)」이라 적혀 있고 개소별 치수조서가
|
||||
따로 없다. 그래서 높이·길이를 받지 않는다. 개소별로 갈리게 하려면 치수 칸이 먼저다.
|
||||
"""
|
||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import DESTINATION
|
||||
|
||||
form = str(options.get("form") or "").strip()
|
||||
if not form:
|
||||
@@ -479,13 +502,16 @@ def soil_guard(options: dict[str, Any]) -> tuple[list[Component], list[str]]:
|
||||
"— 흙막이는 앞면 1:0.3·뒷면 수직·천단 30㎝(교본)라 축이 다르고 근거가 없습니다"
|
||||
]
|
||||
|
||||
notes: list[str] = []
|
||||
components = [
|
||||
Component(name, unit, amount, DESTINATION.get(name, "quantity"), basis)
|
||||
Component(name, unit, amount, destination, basis)
|
||||
for name, unit, amount, basis in SOIL_GUARD_SOD["rows"]
|
||||
if (destination := routed(name, notes))
|
||||
]
|
||||
return components, [
|
||||
*notes,
|
||||
f"떼흙막이는 **{SOIL_GUARD_SOD['unit_label']}**이고 치수가 정본 평균 붙박이입니다"
|
||||
"(상단 1.5 · 하단 1.1 · 높이 0.5 · 두께 0.2 · 기슭 0.54m) — 개소 제원을 안 봅니다"
|
||||
"(상단 1.5 · 하단 1.1 · 높이 0.5 · 두께 0.2 · 기슭 0.54m) — 개소 제원을 안 봅니다",
|
||||
]
|
||||
|
||||
|
||||
@@ -494,10 +520,11 @@ def open_ditch(length_m: float, options: dict[str, Any]) -> tuple[list[Component
|
||||
|
||||
⚠ 규격을 고를 칸이 없어 지금은 **「콘크리트 개거 150×200」 붙박이**다. 그 사실을 줄로 낸다.
|
||||
"""
|
||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import DESTINATION
|
||||
|
||||
if length_m <= 0:
|
||||
return [], ["연장이 없어 전개하지 않음"]
|
||||
from B08_Quantity.B08_Quantity_Wording import option_missing
|
||||
|
||||
return [], [option_missing("length_m", "open_ditch")]
|
||||
spec = str(options.get("ditch_spec") or "콘크리트 개거 150×200").strip()
|
||||
table = OPEN_DITCH_FORMS.get(spec)
|
||||
if table is None:
|
||||
@@ -505,17 +532,19 @@ def open_ditch(length_m: float, options: dict[str, Any]) -> tuple[list[Component
|
||||
f"개거 규격 「{spec}」 원단위가 없습니다 — 있는 것: {' · '.join(OPEN_DITCH_FORMS)}"
|
||||
]
|
||||
|
||||
notes: list[str] = []
|
||||
components = [
|
||||
Component(
|
||||
name,
|
||||
unit,
|
||||
per_m * length_m,
|
||||
DESTINATION.get(name, "quantity"),
|
||||
destination,
|
||||
f"{basis} × 연장 {length_m:g}m (m당 {per_m:g})",
|
||||
spec="".join(spec),
|
||||
)
|
||||
for name, unit, per_m, basis in table["rows"]
|
||||
for name, unit, per_m, basis, *spec in table["rows"]
|
||||
if (destination := routed(name, notes))
|
||||
]
|
||||
notes: list[str] = []
|
||||
if not options.get("ditch_spec"):
|
||||
notes.append(f"규격을 안 골라 「{spec}」으로 섰습니다 — 제원에서 고를 수 있습니다")
|
||||
if spec == "콘크리트 개거 150×200":
|
||||
@@ -538,7 +567,6 @@ def bed_sill(area_m2: float, options: dict[str, Any]) -> tuple[list[Component],
|
||||
등록부 밑수도 `area_m2` 라 축이 그대로 맞는다.
|
||||
⚠ 기울기 몫이 **없다** — 평면에 붙이는 것이라 √(1+n²) 를 곱하지 않는다.
|
||||
"""
|
||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import DESTINATION
|
||||
|
||||
form = str(options.get("form") or "").strip()
|
||||
table = BED_SILL_FORMS.get(form)
|
||||
@@ -583,14 +611,26 @@ def bed_sill(area_m2: float, options: dict[str, Any]) -> tuple[list[Component],
|
||||
# 터파기 — 정본은 「두께 × 1 × 1」이라 **면적 × 두께**와 같다.
|
||||
rows.append(("터파기", "㎥", area_m2 * back, f"면적 × 두께 {back:g}m"))
|
||||
|
||||
notes: list[str] = []
|
||||
components = [
|
||||
Component(name, unit, amount, DESTINATION.get(name, "quantity"), basis, spec="".join(spec))
|
||||
Component(name, unit, amount, destination, basis, spec="".join(spec))
|
||||
for name, unit, amount, basis, *spec in rows
|
||||
if (destination := routed(name, notes))
|
||||
]
|
||||
notes = [f"뒷길이 {back:g}m 는 정본 탭 붙박이 값입니다 — 바닥막이 제원에 뒷길이 칸이 없습니다"]
|
||||
notes.append(
|
||||
f"뒷길이 {back:g}m 는 정본 탭 붙박이 값입니다 — 바닥막이 제원에 뒷길이 칸이 없습니다"
|
||||
)
|
||||
if form == "돌붙임(찰)":
|
||||
notes.append(
|
||||
"버림 콘크리트 0.1 ㎥/㎡ 포함 — 정본 주기 「바닥 10㎝ 이상 콘크리트(버림) 포설 후 "
|
||||
"돌붙임」(KDS 44 90 00 의 100㎜ 와 같음)"
|
||||
)
|
||||
notes.append(BED_SILL_TRENCH_NOTE)
|
||||
return components, notes
|
||||
|
||||
|
||||
#: 바닥막이 터파기 사유 — 표 사유·구조물도 그림이 **같은 말**(2026-09-14 그림에서 드러남).
|
||||
BED_SILL_TRENCH_NOTE = (
|
||||
"⚠ 터파기는 정본대로 면적 × 돌 두께만 — 그 밑 버림·기초잡석 두께는 판 깊이에 안 들어감"
|
||||
" · 옹벽은 버림·잡석까지 팜 — 바닥막이는 정본이 돌 두께만이라 다름"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,611 @@
|
||||
"""구조물 원단위 — **돌쌓기 제원·계수표·판정** · 2026-09-14 `UnitQuantity` 에서 가름.
|
||||
|
||||
뒷길이 계수표 · 돌 종류 계수 · 돌 무게 · 물구멍 · 채움 강도 · 버림·기초잡석 상수 · 표준경사 판정 ·
|
||||
벽 두께. 성분을 세우지 않음 — 세우는 것은 `_Masonry`(전개식)·`UnitQuantity.build_table`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Base import _num
|
||||
|
||||
# ── 계수표 — 식에 박지 않고 여기서 고른다 ─────────────────────────────
|
||||
# 돌 뒷길이(㎝)별 원단위. 출처: `original/실무문서/_원단위라이브러리_울진소광.md` 「돌뒷길이별 원단위표」.
|
||||
# ⚠ 60㎝ 돌중량은 원본이 비어 있다 — 지어내지 않고 None 으로 둔다(PLAN 8-8 ㉮).
|
||||
#: ⚠ **일곱 규격**이다 — 품셈 13-4-3·13-4-4 [주]① 이 25·30·35·45·55·60·75 를 다 준다.
|
||||
#: 앞서 네 칸(35·45·55·60)만 들고 25·30 을 35 로, 75 를 60 으로 **접고** 있었다.
|
||||
#: `stone_ton_per_m2`(돌중량)는 **실무 관측값**이라 그 넷에만 있다 — 없는 칸은 `None`.
|
||||
STONE_BACK_LENGTH_TABLE: dict[int, dict[str, float | None]] = {
|
||||
25: {"fill_concrete_m3_per_m2": 0.11, "wedge_stone_m3_per_m2": None, "stone_ton_per_m2": None},
|
||||
30: {"fill_concrete_m3_per_m2": 0.14, "wedge_stone_m3_per_m2": 0.10, "stone_ton_per_m2": None},
|
||||
35: {"fill_concrete_m3_per_m2": 0.16, "wedge_stone_m3_per_m2": 0.12, "stone_ton_per_m2": 0.575},
|
||||
45: {"fill_concrete_m3_per_m2": 0.20, "wedge_stone_m3_per_m2": 0.15, "stone_ton_per_m2": 0.88},
|
||||
55: {"fill_concrete_m3_per_m2": 0.25, "wedge_stone_m3_per_m2": 0.18, "stone_ton_per_m2": 1.10},
|
||||
60: {"fill_concrete_m3_per_m2": 0.27, "wedge_stone_m3_per_m2": 0.20, "stone_ton_per_m2": None},
|
||||
75: {"fill_concrete_m3_per_m2": 0.34, "wedge_stone_m3_per_m2": 0.25, "stone_ton_per_m2": None},
|
||||
}
|
||||
DEFAULT_BACK_LENGTH_CM = 45
|
||||
|
||||
#: ⑤ 돌 중량이 어디서 온 값인가 (2026-09-08 사용자 확정 2차 ⑤ 「DB 확인 → 없으면 현재 유지,
|
||||
#: 나중에 프로그램에서 확인할 수 있게 근거를 남길 것」).
|
||||
#: ⚠ **품셈·교본에는 돌중량표가 없다** — 지식DB 전수 확인 결과 이 값이 나오는 곳은
|
||||
#: 실무 원단위 라이브러리 한 곳뿐이다(기술문서에는 그 라이브러리를 인용한 줄만 있음).
|
||||
#: 그래서 값을 바꾸지 않고 **어디서 온 값인지**를 줄마다 달아 화면에서 되짚게 한다.
|
||||
STONE_WEIGHT_SOURCE = "uljin_library"
|
||||
STONE_WEIGHT_BASIS = (
|
||||
"⚠ 실무 관측값 — 울진 소광 원단위 라이브러리 「돌뒷길이별 원단위표」(35·45·55㎝만 있음)."
|
||||
" 품셈·교본에는 돌중량표가 없음"
|
||||
)
|
||||
|
||||
#: ⭐ 사용자 확정 5차 큰 것 7 — **돌 무게는 계산식**: `뒷길이 × 0.77 × 2.65`.
|
||||
#: 정본 여섯 탭이 모두 그 식을 그대로 적어 둔다(「2.09 × 0.45 × 0.77 × 2.65 ton/m3」).
|
||||
#: ⚠ **야면석은 그 식이 안 맞는다** — 공극이 커 ㎥당 무게가 달라 원문이 값을 직접 준다
|
||||
#: (35→0.575 · 45→0.880 · 55→1.100 ton/㎡). 그래서 **종류로 갈래를 둔다.**
|
||||
#: ⚠ **잠정이다** — 확정 문구가 「나중에 실무자 협의 후 명확히」이므로 화면 근거에 적는다.
|
||||
STONE_PACKING_RATIO = 0.77 # 채움률 — 돌 사이 공극을 뺀 몫
|
||||
STONE_DENSITY_TON_M3 = 2.65 # 석재 비중
|
||||
#: 계산식이 안 맞아 **관측표**를 쓰는 종류. 안 고른 경우는 **계산식**으로 선다(`stone_masonry` 주석).
|
||||
STONE_WEIGHT_OBSERVED_KINDS = frozenset({"야면석·호박돌", "야면석"})
|
||||
STONE_WEIGHT_FORMULA_BASIS = (
|
||||
"⭐ 확정 5차 — 뒷길이 × 0.77(채움률) × 2.65(비중). ⚠ 잠정 — 실무자 협의 후 명확히 할 값"
|
||||
)
|
||||
|
||||
#: 물구멍 칸 — 2026-09-09 에 등록부에 생겼다(랩탑 메인 `0b0763ed`). **빈 칸이 기본**이다.
|
||||
#: ⚠ 기본 Ø50 은 **국가기준**이고 2㎡당 1개소는 실무 관측값이다(법은 「2~3㎡당 1개소 이상」).
|
||||
WEEP_HOLE_DIAMETER_KEY = "weep_hole_diameter_mm"
|
||||
WEEP_HOLE_AREA_KEY = "weep_hole_area_m2"
|
||||
WEEP_HOLE_DIAMETER_DEFAULT_MM = 50
|
||||
|
||||
|
||||
def weep_hole_spec(options: dict[str, Any]) -> tuple[float, int, str]:
|
||||
"""(개소당 벽면적 ㎡, 관 지름 ㎜, 근거). 안 정하면 기본으로 서고 그 사실이 근거에 남는다."""
|
||||
area = _num(options.get(WEEP_HOLE_AREA_KEY), 0.0)
|
||||
diameter = _num(options.get(WEEP_HOLE_DIAMETER_KEY), 0.0)
|
||||
area_given = area > 0
|
||||
dia_given = diameter > 0
|
||||
if not area_given:
|
||||
area = STONE_MASONRY["weep_hole_area_m2"]
|
||||
if not dia_given:
|
||||
diameter = WEEP_HOLE_DIAMETER_DEFAULT_MM
|
||||
basis = (
|
||||
f"{area:g}㎡당 1개소 · Ø{int(diameter)}"
|
||||
+ ("" if area_given else " · ⚠ 면적 안 정함 — 실무 관측 2㎡(법은 2~3㎡당 1개소 이상)")
|
||||
+ ("" if dia_given else " · ⚠ 지름 안 정함 — 국가기준 Ø50")
|
||||
)
|
||||
return area, int(diameter), basis
|
||||
|
||||
|
||||
def stone_weight_per_m2(
|
||||
back_cm: int, kind: str, observed: float | None
|
||||
) -> tuple[str, float | None, str, str]:
|
||||
"""(줄 이름, ton/㎡, 근거, source) — ⭐ 확정 5차 큰 것 7.
|
||||
|
||||
**계산식이 기본**이고 야면석만 관측표다. 돌쌓기·골막이가 같은 규칙을 쓰도록 여기 한 벌만 둔다.
|
||||
"""
|
||||
if kind in STONE_WEIGHT_OBSERVED_KINDS:
|
||||
basis = (
|
||||
f"× {observed} ton/㎡ (뒷길이 {back_cm}㎝) · {STONE_WEIGHT_BASIS}"
|
||||
if observed is not None
|
||||
else ""
|
||||
)
|
||||
return kind, observed, basis, STONE_WEIGHT_SOURCE
|
||||
ton = back_cm / 100.0 * STONE_PACKING_RATIO * STONE_DENSITY_TON_M3
|
||||
basis = (
|
||||
f"× {ton:.3f} ton/㎡ = 뒷길이 {back_cm / 100.0:g} × {STONE_PACKING_RATIO}"
|
||||
f" × {STONE_DENSITY_TON_M3}"
|
||||
+ (f" · {kind}" if kind else " · ⚠ 돌 종류를 안 골랐음")
|
||||
+ f" · {STONE_WEIGHT_FORMULA_BASIS}"
|
||||
)
|
||||
return kind or "돌", ton, basis, ""
|
||||
|
||||
|
||||
#: 뒷채움(막자갈) 폭 — ⭐ 사용자 확정 5차 작은 것 3 「막자갈 = (상 + 하) ÷ 2 × H」.
|
||||
#: ⚠⚠ **여기 상·하는 벽 두께가 아니라 뒷채움 폭이다.** 벽 두께(0.75/0.9)를 끌어다 쓰면
|
||||
#: H=2.0 에서 실무 0.75 가 1.66(입적)으로 튄다 — 옛 식이 곧 그 자리였다.
|
||||
#: 정본 `04.구조도(기슭막이).xls` **여섯 탭 전부** 상 0.30 · 하 0.45 로 같다
|
||||
#: (H=1.0~3.0 · 메/찰 · 기초유/버림 전수 확인, 2026-09-09).
|
||||
#: ⓘ 소광리는 같은 식에 **0.30 / 0.60** 을 쓴다(그래서 H=2.0 에서 0.900). 문서마다 폭이
|
||||
#: 다르므로 **값이 적힌 구조물은 그 값**을 쓰고, 없으면 이 정본 값으로 서되 그 사실을 알린다.
|
||||
STONE_BACKFILL_WIDTH_M = {"top": 0.30, "bottom": 0.45}
|
||||
|
||||
#: ⚠⚠ 정본 전수 확인이 **H=1.0~3.0** 까지다 — 그보다 높은 벽은 **근거가 없다.**
|
||||
#: 그리고 그 자리에서 품셈 13-4-4 [주]⑨ <뒤채움 조약돌 두께> 범위와 **갈린다**:
|
||||
#: H 3.0 두께 0.359 (품셈 0.33~0.58 안) · H 3.5 0.354 (품셈 0.40~0.70 **아래**)
|
||||
#: 까닭은 이 식의 두께(= 막자갈 ÷ 비탈면적)가 `0.375 ÷ √(1+경사²)` 로 거의 붙박이인데,
|
||||
#: 품셈 표는 직고와 함께 두꺼워지기 때문이다 — **방향이 반대다.**
|
||||
#: ⚠ 교본 7-3 은 「찰 3.0m 이하 · 메 2.0m 이하」로 두지만 **코드가 높이를 막지 않고**
|
||||
#: 표준경사표(13-4-4 [주]⑪)는 직고 7m 까지 칸을 준다 — 즉 **높은 벽이 실제로 설 수 있다.**
|
||||
#: 값을 눅이거나 막지 않고 **그 사실을 사유로 드러낸다**(임의 확정 금지).
|
||||
STONE_BACKFILL_VERIFIED_HEIGHT_M = 3.0
|
||||
|
||||
#: 석적 — ⭐ 확정 5차 작은 것 4. **정본에는 없고 소광리에만 있는 줄**이라 사유에 적는다.
|
||||
#: 소광리 식 `H × ℓ3 × 0.77`(면적 × 뒷길이 × 채움률) 그대로.
|
||||
STONE_PILE_BASIS = "⚠ 정본에 없는 줄 — 소광리 시트에만 있음(정면적 × 뒷길이 × 0.77)"
|
||||
|
||||
|
||||
#: 돌 종류별 계수표 — 품셈 13-4-3·13-4-4 [주]① · 교본 7-3.
|
||||
#: ⚠ 지금까지 **건설품셈 참고자료 한 벌**(돌 종류로 안 갈리는 표)로만 돌고 있었다.
|
||||
#: 그 값이 「깬돌」 계열이라, **자재로는 야면석을 내면서 계수는 깬돌**을 쓰는 어긋남이
|
||||
#: 있었다(2026-09-08 지식DB 대조). 랩탑 창이 `stone_kind` 칸을 만들어 축이 생겼다.
|
||||
STONE_KIND_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_masonry"
|
||||
STONE_KIND_PREFIX = "stone_kind_"
|
||||
STONE_KIND_OPTION = "stone_kind"
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def load_stone_kind_table() -> dict[str, Any]:
|
||||
"""돌 종류별 계수표. 파일이 없으면 **빈 표** — 그러면 종전 값으로 돈다."""
|
||||
folder = STONE_KIND_DIR
|
||||
if not folder.is_dir():
|
||||
return {}
|
||||
files = sorted(folder.glob(STONE_KIND_PREFIX + "*.json"))
|
||||
if not files:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(files[-1].read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
#: ⑨ 계수를 **어느 열로 볼 것인가** (2026-09-08 사용자 확정 2차 ⑨).
|
||||
#: 기본은 **품셈 열**(야면석 고임돌 0.11 · 채움 0.15)이고, 「실무 관행」으로 두면
|
||||
#: 건설품셈 참고자료 한 벌(깬돌 계열 0.15 · 0.20)로 덮어쓴다.
|
||||
#: ⚠ **빈 칸은 「안 정함」**이고 그때 기본(품셈)으로 선다 — 등록부 default 는 비어 있다.
|
||||
#: 기본값을 등록부가 아니라 여기가 갖는 것은 `face_slope_ratio` 와 같은 결이다.
|
||||
COEFF_BASIS_KEY = "stone_coeff_basis"
|
||||
COEFF_BASIS_PRACTICE = {"실무 관행", "실무관행", "practice", "관행"}
|
||||
|
||||
|
||||
def wants_practice_coefficients(options: dict[str, Any]) -> bool:
|
||||
"""계수를 실무 관행 열로 볼 것인가. 빈 칸이면 **아니오**(= 품셈 열)."""
|
||||
return str(options.get(COEFF_BASIS_KEY) or "").strip() in COEFF_BASIS_PRACTICE
|
||||
|
||||
|
||||
def stone_coefficients(options: dict[str, Any], back_cm: int) -> tuple[dict[str, Any], str]:
|
||||
"""(계수 한 벌, 알림). 돌 종류를 안 고르면 **종전 값**으로 돌되 그 사실을 알린다.
|
||||
|
||||
⚠ 값을 못 낸다고 멈추지 않는다 — 이미 저장된 프로젝트가 통째로 비어 버린다.
|
||||
⚠ 표에 「-」(그 규격에 그 돌을 안 씀)면 **지어내지 않고** 사유를 낸다.
|
||||
"""
|
||||
table = load_stone_kind_table()
|
||||
key = str(back_cm)
|
||||
kind = str(options.get(STONE_KIND_OPTION) or "").strip()
|
||||
if not table:
|
||||
return {}, ""
|
||||
if key not in [str(x) for x in (table.get("back_lengths_cm") or [])]:
|
||||
return {}, (f"뒷길이 {back_cm}㎝ 는 품셈 표(25·30·35·45·55·60·75㎝)에 없어 계수가 없습니다")
|
||||
practice = wants_practice_coefficients(options)
|
||||
if not kind or practice:
|
||||
fallback = table.get("fallback") or {}
|
||||
message = (
|
||||
f"「{kind}」이지만 계수를 **실무 관행 열**(건설품셈 참고자료)로 덮어썼습니다 —"
|
||||
" 기본은 품셈 열입니다(확정 ⑨)"
|
||||
if practice and kind
|
||||
else str(fallback.get("message") or "")
|
||||
)
|
||||
return {
|
||||
"wedge_stone_m3_per_m2": (fallback.get("wedge_stone_m3_per_m2") or {}).get(key),
|
||||
"fill_concrete_m3_per_m2": (fallback.get("fill_concrete_m3_per_m2") or {}).get(key),
|
||||
"backfill_ratio": fallback.get("backfill_ratio_of_back_length"),
|
||||
"kind": "",
|
||||
}, message
|
||||
if kind not in (table.get("kinds") or []):
|
||||
return {}, f"「{kind}」은(는) 아는 돌 종류가 아니라 계수를 못 골랐습니다"
|
||||
wedge = ((table.get("wedge_stone_m3_per_m2") or {}).get(kind) or {}).get(key)
|
||||
fill = ((table.get("fill_concrete_m3_per_m2") or {}).get(kind) or {}).get(key)
|
||||
ratio = (table.get("backfill_ratio_of_back_length") or {}).get(kind)
|
||||
note = ""
|
||||
if wedge is None:
|
||||
note = f"품셈 13-4-3 에 「{kind} · 뒷길이 {back_cm}㎝」 칸이 비어 있습니다 — 그 규격에 그 돌을 쓰지 않습니다"
|
||||
return {
|
||||
"wedge_stone_m3_per_m2": wedge,
|
||||
"fill_concrete_m3_per_m2": fill,
|
||||
"backfill_ratio": ratio,
|
||||
"kind": kind,
|
||||
}, note
|
||||
|
||||
|
||||
# 돌쌓기 전개식의 상수 — 실무 수식에 박혀 있던 값을 뺀 것.
|
||||
#: ⚠⚠ **기슭막이 계열(돌쌓기 찰·메) 전용이다. 다른 구조물로 넓히지 말 것**(2026-09-09).
|
||||
#: 골막이는 **더하는 몫이 다르다** — 소광리 숨김탭 「골막이(찰)(치수조서연결)」 C26:
|
||||
#: 골막이 평균두께 = {(ℓ3 + 0.1×H) + (ℓ3 + 0.4×H)} ÷ 2
|
||||
#: 기슭막이 상부 = ℓ3 + 0.30 · 하부 = 상부 + 0.30(H − 1)
|
||||
#: ⚠⚠ **둘 다 뒷길이(ℓ3) 기반이다** — C26 의 「0.45」는 상수가 아니라 `P6/100`(뒷길이 45㎝)을
|
||||
#: 읽은 값이었다(2026-09-09 랩탑 보조가 셀 참조로 확인). 앞서 여기 「0.45 + 0.1H」로 적어
|
||||
#: **상수처럼 보이게** 두었는데, 그렇게 굳으면 **뒷길이를 바꿔도 골막이 값이 안 움직이는**
|
||||
#: 결함이 조용히 남는다.
|
||||
#: ⚠ 상수만 보고 골막이에 가져다 쓰면 **값이 나오므로 아무 시험도 안 잡는다.**
|
||||
#: ⚠ 골막이는 그 밖에도 셋이 더 다르다 — 밑수가 **돌쌓기 + 돌붙임**(기슭막이는 돌쌓기만) ·
|
||||
#: 단위가 **개소당**(기슭막이는 m당) · 정면적이 **사다리꼴**.
|
||||
STONE_MASONRY = {
|
||||
# ⚠ **곱하는 값이 아니라 검산 참고값이다** (2026-09-08 ㉘ 에서 고침).
|
||||
# 실무 시트의 「돌쌓기 = 정면적 × 1.04」에서 그 1.04 가 **곧 기울기 몫**이다
|
||||
# (1:0.3 → √(1+0.3²) = 1.0440 ≈ 1.04). 시트가 반올림해 적은 것을 우리가
|
||||
# **별도 계수로 오해해 `hypot` 위에 또 곱하고 있었다** — 면적이 4 % 부풀었고
|
||||
# 그 면적이 고임돌·야면석·채움콘크리트·모르터·물구멍 **전부의 밑수**였다.
|
||||
# ⚠ 하드코딩하면 안 되는 값이다 — 큰돌쌓기는 「1:0.3 **이상**」이라 기울기가
|
||||
# 바뀔 수 있고, 그때 1.04 는 틀린 값이 되지만 `hypot` 은 따라간다.
|
||||
"sheet_check_factor_at_0_3": 1.04, # 1:0.3 에서 시트값과 맞는지 대조하는 자리
|
||||
# ⭐ 2026-09-09 확정 2차 ② — **실무 구조물도 식**으로 갈았음(랩탑 보조가 소광리 원본
|
||||
# 네 탭에서 찾아 실측과 소수점까지 대조).
|
||||
# 상부 두께 = 뒷길이(ℓ3) + 0.30
|
||||
# 하부 두께 = 상부 + 0.30 × (H − 1.0)
|
||||
# ⚠ **옛 식에는 뒷길이가 없었다**(0.45 + 0.10·H / 0.45 + 0.40·H) — 뒷길이를 35 로
|
||||
# 바꿔도 두께가 안 바뀌어 돌 부피·막자갈·터파기가 다 같은 값으로 섰다.
|
||||
# ⚠ 정본 xls 의 「상부 0.75 · 하부 0.90」은 **H=1.5 규칙값을 다른 높이 탭에 복사**한
|
||||
# 흔적이라 그대로 쓰면 H=1.5 밖에서 틀린다.
|
||||
# ⚠ 돌조공(H=0.5 · 0.55/0.65)은 이 규칙 밖 — 다른 시설이라 여기서 다루지 않는다.
|
||||
"thickness_top_add_m": 0.30, # 상부 = 뒷길이 + 0.30
|
||||
"thickness_slope_per_m": 0.30, # 하부 = 상부 + 0.30 × (H − 1.0)
|
||||
"thickness_height_base_m": 1.0, # 그 식의 기준 높이
|
||||
# ⚠ 미결 — 법은 「2~3 ㎡당 1개소 **이상**」(구조물_수량.md §물구멍)이고 2.0 은 **실무 관측값**이다.
|
||||
# 범위의 한쪽 끝을 쓰는 것이라 사용자 확정 전까지 잠정이다. 식이 아니라 여기 있으니 갈아끼우면 된다.
|
||||
"weep_hole_area_m2": 2.0, # 물구멍 1개소당 벽면적
|
||||
# ⚠ **구조물마다 다른 값이다** — 실무 관측: 반중력식 옹벽 0.32 m/m · 돌기슭막이 0.39 m ·
|
||||
# 돌골막이 0.5 m/개소(2026-09-08 랩탑 보조). 여기 0.5 하나로 돌고 있으니 그 사실을
|
||||
# 적어 둔다 — 관측 원단위가 있는 종류는 그 표가 이기고, 없는 종류만 이 값으로 선다.
|
||||
# ⓘ 2026-09-13 — 돌쌓기(기슭막이)는 **평균두께**로 갈음(브레인 판정).
|
||||
# 이 값은 골막이 정본용으로만 남음.
|
||||
"weep_hole_length_m": 0.5, # 물구멍관 1개소당 관 길이 — 골막이
|
||||
"mortar_m3_per_m2": 0.009, # 줄눈 모르터 (찰쌓기만)
|
||||
# ⚠ 터파기 여유폭·되메우기 두께는 **공용 한 벌**로 옮겼다
|
||||
# (`common_util/common_util_excavation.py` — 화면 B06 과 짝인 TS 가 있다).
|
||||
# 여기서 다시 두면 그림과 물량이 갈린다.
|
||||
}
|
||||
|
||||
# 성분이 어디로 가는가 — 이중계상을 막는 표시.
|
||||
# `earthwork` = 토공 대분류로 합산(울진 토적집계 D12~D14 실증)
|
||||
# `material` = 자재총괄로 감(할증은 거기서 한 번만)
|
||||
# `unit_price` = 일위대가 재료비 구성으로 감(B09 가 배합을 분해)
|
||||
#: 버림 콘크리트 — **빠뜨리고 있던 줄**이다(2026-09-09 사용자 확정 ⑭).
|
||||
#: 두께 근거: KDS 44 90 00 도로암거구조설계기준 「기초시공시 기초지반 다짐을 시행하고
|
||||
#: 구조물 시공이 원활하도록 **100 mm 두께의 버림콘크리트**를 타설하도록 한다」.
|
||||
#: 폭 근거: KCS 34 50 05 「버림 콘크리트의 두께는 설계도서에 따르며, **폭은 잡석다짐의
|
||||
#: 폭과 동일**하게 한다」.
|
||||
#: ⭐ 그 잡석다짐 폭이 **정해졌다** — 「기준 구조물에서 유도, 같은 값을 공유」
|
||||
#: (2026-09-08 사용자 확정 2차 ⑪ 「기슭막이면 **하단 길이** × 종단방향 폭 × 높이」).
|
||||
#: ⇒ 벽에서 **하단 길이 = 하부 두께**다. 종전의 「터파기 폭 잠정」을 그 값으로 바꾼다.
|
||||
#: ⚠ **기초잡석(품셈 12-25) 물량은 아직 못 센다** — 폭은 이제 있으나 **두께가 어느
|
||||
#: 원문에도 없다**(품셈은 ㎥당 품만 줌). 실무 관측은 관보호공 날개벽 T=0.2 하나뿐이라
|
||||
#: **다른 구조물 값을 옮겨 쓰지 않고** 사유로 드러낸다.
|
||||
#: 돌을 **사 오나 캐나** — 기본은 「캔다」(2026-09-09 사용자 확정 ②).
|
||||
#: 법이 그쪽을 권한다 — 별표2 「석축 등에 필요한 야면석 등은 **가급적 현장에서
|
||||
#: 채취·사용**하도록 운반거리를 조사한다」. 실무 견적 다섯 권에도 야면석 **구입 단가가
|
||||
#: 0건**이고 울진 일위대가는 「구입/채집」 두 벌을 갖고 있다.
|
||||
#: ⚠ 구조물마다 바꿀 수 있다 — 저장 제원에 「구입」이라 적힌 구조물만 공제에서 빠진다.
|
||||
STONE_SUPPLY_KEYS = ("stone_supply", "stone_source")
|
||||
STONE_SUPPLY_PURCHASED = {"구입", "구입품", "사서", "purchase", "purchased", "buy"}
|
||||
#: 조달을 안 정한 구조물의 사유 — 셈은 확정 ② 「기본은 캔다」대로 채집이되 **안 정한 사실**을 보임.
|
||||
STONE_SUPPLY_DEFAULT_NOTE = "돌 조달(채집·구입)을 안 정해 「채집」으로 셈 — 사용자 확정 ② 기본 · 구조물 상세에서 고르면 바뀜"
|
||||
|
||||
#: ⚠⚠ **채집석 공제는 사토에서 한 번만 뺀다** (2026-09-09 세 창 확정 · 랩탑 메인의 통로에도
|
||||
#: 같은 문장이 박혀 있다 — 두 곳이 같은 말이라야 나중에 누가 봐도 안 갈린다).
|
||||
#:
|
||||
#: 채집석 공제는 사토에서 한 번만 뺀다.
|
||||
#: B08 은 소요량(collected_stone_deduction_m3, ㎥ 양수)을 내기만 하고 공제하지 않으며,
|
||||
#: 빼는 자리는 유토곡선의 사토뿐이다 —
|
||||
#: 실어 내는 몫(spoil_m3 − natural_spoil_m3)에서 먼저 빼고 모자라면 자연방토에서 뺀다.
|
||||
#:
|
||||
#: ⚠ **부호를 넘기지 않는다.** 실무 시트가 `−274.66` 으로 적혀 있어 그대로 넘기면 두 번
|
||||
#: 뒤집힌다. 우리는 **양수**로 주고, 빼는 것은 받는 쪽이 한다.
|
||||
COLLECTED_STONE_KEY = "collected_stone_deduction_m3"
|
||||
|
||||
#: ⑩ 채움 콘크리트 강도 (2026-09-08 사용자 확정 2차 ⑩ 「기본 210, 고를 수 있게」).
|
||||
#: ⚠ **빈 칸은 「안 정함」**이고 그때 210 으로 선다 — 등록부 default 는 비어 있다.
|
||||
#: 180 은 국가기준 하한(돌쌓기 전용 값)이라 고르면 그 값이 이긴다.
|
||||
FILL_CONCRETE_MPA_KEY = "fill_concrete_mpa"
|
||||
FILL_CONCRETE_MPA_DEFAULT = "210"
|
||||
FILL_CONCRETE_MPA_CHOICES = ("180", "210")
|
||||
|
||||
|
||||
def fill_concrete_mpa(options: dict[str, Any]) -> tuple[str, str]:
|
||||
"""(강도, 근거). 안 정했으면 기본 210 이고 그 사실이 근거에 적힌다."""
|
||||
value = str(options.get(FILL_CONCRETE_MPA_KEY) or "").strip()
|
||||
if value in FILL_CONCRETE_MPA_CHOICES:
|
||||
return value, f"강도 {value} — 저장 제원에서 고른 값"
|
||||
if value:
|
||||
return (
|
||||
FILL_CONCRETE_MPA_DEFAULT,
|
||||
f"강도 {FILL_CONCRETE_MPA_DEFAULT} — ⚠ 저장값 「{value}」은 180·210 중에"
|
||||
" 없어 기본으로 섬",
|
||||
)
|
||||
return (
|
||||
FILL_CONCRETE_MPA_DEFAULT,
|
||||
f"강도 {FILL_CONCRETE_MPA_DEFAULT} — 안 정해 기본값(확정 ⑩)",
|
||||
)
|
||||
|
||||
|
||||
BLINDING_THICKNESS_M = 0.10
|
||||
|
||||
#: 기초잡석 두께(m) — 2026-09-09 사용자 확정 3차 ② 「0.2 m」.
|
||||
#: ⚠ 품셈 12-25 는 **㎥당 품만** 주고 두께를 정하지 않는다(전 출처 소진). 폭은 **버림 폭과
|
||||
#: 같고**(KCS 34 50 05 「버림 콘크리트의 폭은 잡석다짐의 폭과 동일」) 그 폭은 확정 ⑪ 로
|
||||
#: 하단 길이다. ⇒ **기초잡석 = 버림 × (잡석두께 ÷ 버림두께)** 로 나온다 — 같은 폭·연장이라
|
||||
#: 두께 비만 곱하면 된다. 관측 원단위로 오는 구조물(옹벽)도 같은 식으로 선다
|
||||
#: (버림 0.15㎥/m ÷ 0.1 = 폭 1.5m ⇒ 잡석 0.30㎥/m — 확정 3차 ② 의 그 값).
|
||||
RUBBLE_BASE_THICKNESS_M = 0.2
|
||||
|
||||
|
||||
#: 버림을 뺄 수 있는 칸 — 「기본은 넣고, 빼고 싶으면 뺀다」(사용자 확정 ⑭).
|
||||
#: 저장 제원에 이 칸이 없으면 **넣는 쪽**이 기본이다.
|
||||
BLINDING_OPTION_KEYS = ("blinding_concrete", "base_blinding")
|
||||
|
||||
|
||||
#: 표준경사표 — 품셈 13-4-4 [주]⑪. 파일이 없으면 종전 기본값(0.3)으로 돈다.
|
||||
SLOPE_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_masonry"
|
||||
SLOPE_PREFIX = "masonry_slope_"
|
||||
#: 사용자가 덮어쓰는 칸 — 값이 있으면 **그 값이 이긴다**(확정 ⑨).
|
||||
FACE_SLOPE_KEYS = ("face_slope_ratio",)
|
||||
#: 성토/절토는 **측점 단면유형(`design.section_mode`) + 설치 측**으로 판정한다
|
||||
#: (`common_util_structure_face_role` 한 벌). 못 가르는 자리(`both_cut` + 「자동」)만 남아
|
||||
#: 그때 쓸 칸을 여기 한 곳에 둔다 — **눅여 쓰는 것이 아니라 종전값으로 서고 사유를 적는다.**
|
||||
DEFAULT_SLOPE_FACE = "성토"
|
||||
LEGACY_FACE_SLOPE_RATIO = 0.3
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def load_slope_table(path: str | None = None) -> dict[str, Any]:
|
||||
"""표준경사표. 없으면 빈 표 — 그때는 종전 기본값으로 돈다(값을 지어내지 않는다)."""
|
||||
target = Path(path) if path else None
|
||||
if target is None:
|
||||
files = sorted(SLOPE_DIR.glob(SLOPE_PREFIX + "*.json")) if SLOPE_DIR.is_dir() else []
|
||||
target = files[-1] if files else None
|
||||
if target is None or not target.is_file():
|
||||
return {}
|
||||
return json.loads(target.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def face_slope_ratio(
|
||||
options: dict[str, Any],
|
||||
*,
|
||||
wet: bool,
|
||||
height_m: float,
|
||||
face: str | None = None,
|
||||
face_reason: str = "",
|
||||
table: dict[str, Any] | None = None,
|
||||
) -> tuple[float, str]:
|
||||
"""(기울기 1:n 의 n, 근거 문구).
|
||||
|
||||
⚠ **사용자가 정했으면 그 값이 이긴다**(확정 ⑨). 안 정했으면 품셈 13-4-4 [주]⑪
|
||||
<표준경사>에서 **직고 · 메/찰 · 성토/절토** 셋으로 고른다.
|
||||
⚠ 자동으로 고른 값을 **저장에 박지 않는다** — 「정한 적 없음」과 「0.3 으로 정함」이
|
||||
구별돼야 한다(오늘 패널 접힘에서 지킨 것과 같은 자리).
|
||||
⚠ 경계값은 **앞 칸**이다 — 원문이 `∼3` 이라 직고 3.0m 은 `~3` 칸이다.
|
||||
"""
|
||||
for key in FACE_SLOPE_KEYS:
|
||||
given = _num(options.get(key), 0.0)
|
||||
if options.get(key) is not None and given > 0:
|
||||
return given, f"사용자 지정 1:{given:g}"
|
||||
|
||||
found = table if table is not None else load_slope_table()
|
||||
steps = found.get("steps_m") or []
|
||||
bond = "찰쌓기" if wet else "메쌓기"
|
||||
# ⚠ 성토/절토는 **판정 한 벌**(`common_util_structure_face_role`)이 준다. 못 가르면
|
||||
# `None` 이 오는데, **성토로 눅이지 않는다** — 임의값이 금액으로 굳으면 안 된다.
|
||||
# 그때는 종전값으로 서되 **왜 못 갈랐는지**를 근거에 적는다.
|
||||
if not face:
|
||||
why = face_reason or "성절토를 가를 근거 없음"
|
||||
return (
|
||||
LEGACY_FACE_SLOPE_RATIO,
|
||||
f"⚠ {why} — 표준경사표를 못 골라 종전값 1:{LEGACY_FACE_SLOPE_RATIO} 로 섰음",
|
||||
)
|
||||
row = ((found.get("table") or {}).get(bond) or {}).get(face)
|
||||
if not row:
|
||||
return LEGACY_FACE_SLOPE_RATIO, f"표준경사표를 못 읽어 종전값 1:{LEGACY_FACE_SLOPE_RATIO}"
|
||||
|
||||
index = len(steps)
|
||||
for i, limit in enumerate(steps):
|
||||
if height_m <= float(limit):
|
||||
index = i
|
||||
break
|
||||
ratio = float(row[min(index, len(row) - 1)])
|
||||
label = f"직고 {height_m:g}m" + (f" ≤{steps[index]:g}m" if index < len(steps) else " 7m 초과")
|
||||
note = f"품셈 13-4-4 [주]⑪ 표준경사 · {bond} {face} · {label} → 1:{ratio:g}"
|
||||
if face_reason:
|
||||
note += f" · {face_reason}"
|
||||
return ratio, note
|
||||
|
||||
|
||||
def is_collected_stone(options: dict[str, Any]) -> bool:
|
||||
"""이 구조물의 돌을 **캐서 쓰는가**. 정한 적이 없으면 「캔다」(확정 ②)."""
|
||||
for key in STONE_SUPPLY_KEYS:
|
||||
raw = options.get(key)
|
||||
if raw is None:
|
||||
continue
|
||||
if str(raw).strip().lower() in STONE_SUPPLY_PURCHASED:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def wants_blinding(options: dict[str, Any]) -> bool:
|
||||
"""버림 콘크리트를 넣을지. **정한 적이 없으면 넣는다**(사용자 확정 ⑭).
|
||||
|
||||
「안 넣음」·「제외」·`false` 로 적혀 있을 때만 뺀다 — 빈 칸을 「빼기」로 읽으면
|
||||
저장해 둔 적 없는 프로젝트에서 줄이 통째로 사라진다.
|
||||
"""
|
||||
for key in BLINDING_OPTION_KEYS:
|
||||
raw = options.get(key)
|
||||
if raw is None:
|
||||
continue
|
||||
text = str(raw).strip().lower()
|
||||
if text in {"false", "0", "no", "제외", "안 넣음", "안넣음", "빼기"}:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
#: ⚠ **저장 제원의 실제 칸 이름**은 `back_len_cm` 이다(레지스트리 확인).
|
||||
#: 앞서 `stone_back_length_cm` 을 읽고 있어 **저장값이 영영 안 닿고 늘 기본 45㎝ 로 돌았다**
|
||||
#: — 뒷길이를 75 로 골라도 45 계수가 붙던 자리다. 값이 나오므로 아무 시험도 안 잡았다.
|
||||
#: 옛 이름도 함께 본다(다른 곳에서 그 이름으로 넣어 줄 수 있다).
|
||||
BACK_LENGTH_KEYS = ("back_len_cm", "stone_back_length_cm")
|
||||
|
||||
|
||||
#: 메쌓기 높이 — **두 기준이 공존**(2026-09-14 브레인 판정 ㉰ · 지식DB 대조 정합):
|
||||
#: 임도기술교본 7-3:27 「돌흙막이 높이는 원칙적으로 찰쌓기는 3.0m 이하, 메쌓기는 2.0m 이하」 ↔
|
||||
#: 품셈 13-4-4 [주]⑪ 원문 L7192 「돌쌓기는 높이 3m 이상이면 전부 또는 하부를 찰쌓기로 한다」.
|
||||
#: 막지 않고 사유만(판정 ① 「놓기를 막지 말 것」과 한 결).
|
||||
DRY_MASONRY_GUIDE_M = 2.0
|
||||
DRY_MASONRY_LIMIT_M = 3.0
|
||||
|
||||
|
||||
def dry_masonry_height_note(height_m: float) -> str | None:
|
||||
"""메쌓기 높이가 기준을 넘으면 사유 한 줄 — 두 기준을 다 적음."""
|
||||
if height_m >= DRY_MASONRY_LIMIT_M:
|
||||
return (
|
||||
f"⚠ 메쌓기 높이 {height_m:g}m — 품셈 13-4-4 [주]⑪(원문 L7192) 「돌쌓기는 높이 3m 이상이면"
|
||||
" 전부 또는 하부를 찰쌓기로 한다」 · 임도기술교본 7-3:27 은 메쌓기 2.0m 이하 — 막지 않음(설계자 확인)"
|
||||
)
|
||||
if height_m > DRY_MASONRY_GUIDE_M:
|
||||
return (
|
||||
f"⚠ 메쌓기 높이 {height_m:g}m — 임도기술교본 7-3:27 「메쌓기는 2.0m 이하」를 넘음"
|
||||
" · 품셈 13-4-4 [주]⑪(원문 L7192)은 3m 이상부터 찰쌓기 — 두 기준이 다름 · 막지 않음"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
#: 뒷길이 표준표 — 품셈 13-4-4 [주]⑩. 안 고른 돌쌓기는 붙박이 45 가 아니라 이 표의 하한
|
||||
#: (2026-09-14 브레인 판정 ㉮ 「범위 안에 드는 규격 중 가장 작은 것」). 파일이 없으면 종전 45.
|
||||
BACK_LENGTH_PREFIX = "masonry_back_length_"
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def load_back_length_table() -> dict[str, Any]:
|
||||
"""뒷길이 표준표. 없으면 빈 표 — 그때는 종전 기본 45㎝ 로 돎."""
|
||||
files = sorted(SLOPE_DIR.glob(BACK_LENGTH_PREFIX + "*.json")) if SLOPE_DIR.is_dir() else []
|
||||
return json.loads(files[-1].read_text(encoding="utf-8")) if files else {}
|
||||
|
||||
|
||||
def standard_back_length(*, wet: bool, height_m: float) -> tuple[int, str]:
|
||||
"""(제안 뒷길이 ㎝, 근거) — 높이·메/찰 칸 범위 안 품셈 규격 중 가장 작은 것.
|
||||
|
||||
⚠ 하한이 규격(25·30·35·45·55·60·75)에 없으면 **그 위 첫 규격** — 메쌓기 ~3m 하한 36 은 45.
|
||||
내리면 품셈 범위 밖이 됨. 경계값은 표준경사표와 같이 **앞 칸**(높이 3.0m → `~3`).
|
||||
"""
|
||||
table = load_back_length_table()
|
||||
steps = table.get("steps_m") or []
|
||||
bond = "찰쌓기" if wet else "메쌓기"
|
||||
rows = (table.get("table_cm") or {}).get(bond) or []
|
||||
if not rows:
|
||||
return DEFAULT_BACK_LENGTH_CM, f"뒷길이 표준표를 못 읽어 종전 {DEFAULT_BACK_LENGTH_CM}㎝"
|
||||
index = next((i for i, limit in enumerate(steps) if height_m <= float(limit)), len(steps))
|
||||
low, high = rows[min(index, len(rows) - 1)]
|
||||
label = f"직고 {height_m:g}m" + (f" ≤{steps[index]:g}m" if index < len(steps) else " 7m 초과")
|
||||
span = f"{low}~{high}㎝" if high is not None else f"{low}㎝ 이상"
|
||||
picked = next(
|
||||
(
|
||||
size
|
||||
for size in sorted(STONE_BACK_LENGTH_TABLE)
|
||||
if size >= low and (high is None or size <= high)
|
||||
),
|
||||
None,
|
||||
)
|
||||
head = f"품셈 13-4-4 [주]⑩ 뒷길이 표준(원문 L7172~7181) · {bond} {label} {span}"
|
||||
if picked is None:
|
||||
return (
|
||||
DEFAULT_BACK_LENGTH_CM,
|
||||
f"{head} 안에 품셈 규격이 없어 종전 {DEFAULT_BACK_LENGTH_CM}㎝",
|
||||
)
|
||||
tail = "" if picked == low else f"(하한 {low}㎝ 는 규격 일곱(원문 L7152)에 없어 그 위 첫 규격)"
|
||||
return picked, f"{head} → 하한 {picked}㎝{tail}"
|
||||
|
||||
|
||||
def _back_length(
|
||||
options: dict[str, Any], *, wet: bool | None = None, height_m: float | None = None
|
||||
) -> int:
|
||||
"""저장된 뒷길이(㎝)를 **그대로** 돌려준다. 안 정했으면 뒷길이 표준표 하한(메/찰·높이를 받으면),
|
||||
못 받는 자리(골막이 등)는 종전 기본 45.
|
||||
|
||||
⚠ **접지 않는다.** 품셈 13-4-3·13-4-4 [주]① 이 25·30·35·45·55·60·75 **일곱 규격**을
|
||||
다 주므로 접을 까닭이 없다. 그 밖의 값(40 등)은 **계수가 없다고 드러낸다** —
|
||||
접으면 다른 규격 계수가 조용히 돈다.
|
||||
"""
|
||||
for key in BACK_LENGTH_KEYS:
|
||||
raw = options.get(key)
|
||||
if raw is None:
|
||||
continue
|
||||
try:
|
||||
value = int(float(raw))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
# ⚠ **접지 않는다.** 앞서 「가장 가까운 위 칸」으로 접고 있었는데, 그러면 40㎝ 가
|
||||
# 45㎝ 계수로 **조용히** 돌고 999㎝ 도 60㎝ 로 접혔다(2026-09-08 실측).
|
||||
# 표에 없으면 그 값을 그대로 돌려주고, 계수를 고르는 쪽이 「없다」고 드러낸다.
|
||||
return value
|
||||
if wet is None or height_m is None:
|
||||
return DEFAULT_BACK_LENGTH_CM
|
||||
return standard_back_length(wet=wet, height_m=height_m)[0]
|
||||
|
||||
|
||||
def back_length_default_note(
|
||||
options: dict[str, Any],
|
||||
back_cm: int,
|
||||
*,
|
||||
wet: bool | None = None,
|
||||
height_m: float | None = None,
|
||||
) -> str | None:
|
||||
"""뒷길이를 안 골라 기본으로 섰을 때의 한 줄 — 표 사유·구조물도 그림이 **같은 말**을 씀."""
|
||||
if any(options.get(key) not in (None, "") for key in BACK_LENGTH_KEYS):
|
||||
return None
|
||||
if wet is not None and height_m is not None:
|
||||
return (
|
||||
f"⚠ 뒷길이를 안 골라 {standard_back_length(wet=wet, height_m=height_m)[1]} 로 섰습니다 "
|
||||
"— 구조물 상세에서 고르면 바뀜 · 그 값이 두께식·고임돌·돌 무게·채움콘크리트 계수를 모두 가릅니다"
|
||||
)
|
||||
return (
|
||||
f"⚠ 뒷길이를 안 골라 기본 {back_cm}㎝ 로 섰습니다 "
|
||||
"— 그 값이 두께식·고임돌·돌 무게·채움콘크리트 계수를 모두 가릅니다"
|
||||
)
|
||||
|
||||
|
||||
#: 벽 두께를 **사용자가 덮어쓰는 칸** — 비우면 실무 구조물도 식으로 돈다(확정 2차 ② 후반
|
||||
#: 「사용자가 값을 바꿀 수 있게」). 야면석 계수·채움 강도와 같은 자리다.
|
||||
WALL_THICKNESS_KEYS: tuple[str, str] = ("thickness_top_m", "thickness_bottom_m")
|
||||
|
||||
|
||||
def wall_thickness(
|
||||
options: dict[str, Any], *, back_cm: float, height_m: float
|
||||
) -> tuple[float, float, str]:
|
||||
"""(상부 두께 m, 하부 두께 m, 근거 문구) — **사용자가 넣은 값이 식을 이긴다.**
|
||||
|
||||
⚠ 빈 칸은 「안 정함」이다 — 자동으로 나온 값을 저장에 박지 않는다(`face_slope_ratio`
|
||||
와 같은 태도). 상부만 넣으면 하부는 **그 상부 위에서** 식을 이어 간다 — 벽 모양
|
||||
(아래로 벌어지는 기울기)을 사용자 값 위에 그대로 얹는 것이 자연스럽다.
|
||||
"""
|
||||
|
||||
def given(key: str) -> float | None:
|
||||
raw = options.get(key)
|
||||
if raw in (None, ""):
|
||||
return None
|
||||
try:
|
||||
value = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return value if value > 0 else None
|
||||
|
||||
constants = STONE_MASONRY
|
||||
back_m = back_cm / 100.0
|
||||
top_given = given(WALL_THICKNESS_KEYS[0])
|
||||
top = top_given if top_given is not None else back_m + constants["thickness_top_add_m"]
|
||||
rise = constants["thickness_slope_per_m"] * max(
|
||||
height_m - constants["thickness_height_base_m"], 0.0
|
||||
)
|
||||
bottom_given = given(WALL_THICKNESS_KEYS[1])
|
||||
bottom = bottom_given if bottom_given is not None else top + rise
|
||||
top_basis = (
|
||||
f"상부 {top:.2f}(사용자 입력)"
|
||||
if top_given is not None
|
||||
else f"상부 {top:.2f}(뒷길이 {back_cm:g}㎝ + {constants['thickness_top_add_m']:g})"
|
||||
)
|
||||
bottom_basis = (
|
||||
f"하부 {bottom:.2f}(사용자 입력)"
|
||||
if bottom_given is not None
|
||||
else (
|
||||
f"하부 {bottom:.2f}(상부 + {constants['thickness_slope_per_m']:g}"
|
||||
f"×(H−{constants['thickness_height_base_m']:g}))"
|
||||
)
|
||||
)
|
||||
return top, bottom, f"실무 구조물도 식 — {top_basis} · {bottom_basis}"
|
||||
@@ -30,16 +30,32 @@ 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,
|
||||
ROOT_REMOVAL_EXCAVATOR_SIZES,
|
||||
ROOT_REMOVAL_EXCAVATOR_SUGGESTED,
|
||||
SEED_SPRAY_GROUNDS,
|
||||
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_HaulSummary import build_table as build_haul_table
|
||||
from B08_Quantity.B08_Quantity_Engine_HaulSummary import check_against_plan
|
||||
from B08_Quantity.B08_Quantity_Engine_Handoff import load_mapping
|
||||
from B08_Quantity.B08_Quantity_Engine_HaulSummary import build_table as build_haul_table
|
||||
from B08_Quantity.B08_Quantity_Engine_HaulSummary import (
|
||||
borrow_of,
|
||||
check_against_plan,
|
||||
summary_input_rows,
|
||||
)
|
||||
from B08_Quantity.B08_Quantity_Engine_RockSplit import apply_rock_split
|
||||
from B08_Quantity.B08_Quantity_Router_Earthwork_HaulPlan import HAUL_PLAN_KEYS
|
||||
from B08_Quantity.B08_Quantity_Router_Earthwork_HaulPlan import (
|
||||
recompute_haul_plan as _recompute_haul_plan,
|
||||
)
|
||||
from B08_Quantity.B08_Quantity_Engine_Preparation import build_table as build_preparation_table
|
||||
from B08_Quantity.B08_Quantity_Engine_HaulSummary import summary_input_rows
|
||||
from B08_Quantity.B08_Quantity_Engine_SlopeArea import build_table as build_slope_table
|
||||
from B08_Quantity.B08_Quantity_Engine_SlopeLength import station_slopes
|
||||
from B08_Quantity.B08_Quantity_Engine_SlopeLength import road_surface_area, station_slopes
|
||||
from B08_Quantity.B08_Quantity_Provenance import quantity_provenance
|
||||
from common_util.common_util_project_settings import (
|
||||
CONCRETE_PLACING_METHODS,
|
||||
@@ -47,16 +63,20 @@ from common_util.common_util_project_settings import (
|
||||
application_ratio,
|
||||
concrete_placing_method,
|
||||
earthwork_conversion_choices,
|
||||
earthwork_conversion_factors,
|
||||
mixed_conversion_factors,
|
||||
haul_limit_choice,
|
||||
quantity_settings,
|
||||
rock_classes,
|
||||
rock_method,
|
||||
save_section,
|
||||
topsoil_target,
|
||||
)
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import run_with_connection
|
||||
from config.config_system_design import (
|
||||
EARTHWORK_CONVERSION_FACTORS,
|
||||
EARTHWORK_CONVERSION_PUMSEM_C_RANGES,
|
||||
EARTHWORK_HAUL_EQUIPMENT_LIMITS_M,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -86,10 +106,13 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
|
||||
)
|
||||
# ⚠ 설정을 **먼저** 읽는다 — 토량환산계수를 프로젝트가 골랐으면 표가 그 값으로 서야 한다.
|
||||
settings, project_root = await _project_settings(project_id)
|
||||
factors = earthwork_conversion_factors(settings)
|
||||
# 암은 구성비 가중 C(㉱ (나)) — 유토곡선(B06)·운반표와 같은 함수.
|
||||
factors = mixed_conversion_factors(settings)
|
||||
table = build_table(_stations(designs), factors)
|
||||
# 화면이 「무엇을 골랐나 · 품셈 범위 안인가」를 보이는 데 쓴다. 계산에는 안 들어간다.
|
||||
table["conversion_factor_choices"] = earthwork_conversion_choices(settings)
|
||||
# 도쟈 한계거리 — 지금 값·기본값·근거를 함께 보인다(유토곡선 장비 경계, 2026-09-13 판정).
|
||||
table["haul_limit_choice"] = haul_limit_choice(settings)
|
||||
# 품셈 암종별 범위 — **화면 안내용**이다. 정의처가 서버 한 곳이라 내려보내 쓴다
|
||||
# (프론트에 다시 적으면 두 벌이 되어 갈린다).
|
||||
table["conversion_factor_pumsem_ranges"] = [
|
||||
@@ -97,15 +120,21 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
|
||||
for name, low, high in EARTHWORK_CONVERSION_PUMSEM_C_RANGES
|
||||
]
|
||||
# 사면 계열은 저장된 설계선에서 유도한다.
|
||||
slope = build_slope_table(station_slopes(designs))
|
||||
slopes = station_slopes(designs)
|
||||
slope = build_slope_table(slopes)
|
||||
table["slope"] = slope
|
||||
|
||||
plan = await _stored_haul_plan(project_id, route_id)
|
||||
haul = build_haul_table(plan, factors)
|
||||
# ㉱ (가) 암은 흙깎기와 같은 구성비·시공법으로 가름 — B06 리핑암은 자리표시(8-1 · 2026-09-14 브레인).
|
||||
classes = rock_classes(settings)
|
||||
methods = {name: rock_method(settings, name) for name in classes}
|
||||
apply_rock_split(haul, classes, settings.get("rock_ratios_pct") or {}, methods)
|
||||
# 사토 — **운반 줄이 되는 값**인데 유토곡선의 띠·이동에는 안 들어 있다(잔량으로 남는다).
|
||||
# 여기서 그 값을 운반표에 실어 인계가 「사토 운반」 한 줄을 세우게 한다.
|
||||
# ⚠ 거리는 품셈이 정하지 않는다 — 설계 입력(`spoil_site_distance_m`)이고 없으면 막힌다.
|
||||
haul["spoil"] = _spoil_of(plan, settings, _spoil_sites(designs))
|
||||
haul["borrow"] = borrow_of(plan) # 토취(반입토) — 수량만 · 금액은 사유(브레인 ①)
|
||||
# 배수관 연장 — B06 이 측점 `design.pipe_length_m` 에 남긴 값. **여기서 짓지 않는다.**
|
||||
# 인계가 관 줄을 세울 때 쓴다. 단면을 두 번 읽지 않으려고 이 응답에 실어 보낸다.
|
||||
table["pipe_lengths"] = [
|
||||
@@ -138,14 +167,43 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
|
||||
earthwork_totals=table.get("totals") or {},
|
||||
slope_totals=slope.get("totals") or {},
|
||||
haul_rows=summary_input_rows(haul),
|
||||
borrow_m3=(haul["borrow"] or {}).get("volume_m3") or 0.0,
|
||||
borrow_sites=(haul["borrow"] or {}).get("sites") or [],
|
||||
rock_classes=rock_classes(settings),
|
||||
rock_ratios_pct=settings.get("rock_ratios_pct") or {},
|
||||
application_ratios={
|
||||
key: application_ratio(settings, key)
|
||||
for key in (settings.get("application_ratios_pct") or {})
|
||||
},
|
||||
# 노체다짐 — 기본 꺼짐. 켠 프로젝트에서만 줄이 선다(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],
|
||||
},
|
||||
}
|
||||
# 제근 굴착기 크기 — 선택지·제안(회색 · [제안값 넣기])은 서버 한 곳(2026-09-14 브레인).
|
||||
table["root_removal_excavator_choices"] = {
|
||||
"choices": list(ROOT_REMOVAL_EXCAVATOR_SIZES),
|
||||
"suggested": {
|
||||
"value": ROOT_REMOVAL_EXCAVATOR_SUGGESTED[0],
|
||||
"basis": ROOT_REMOVAL_EXCAVATOR_SUGGESTED[1],
|
||||
},
|
||||
}
|
||||
# 초류종자살포 비탈면 토질 — 5-24 잎 둘(서버 한 곳) · 제안값 없음(2026-09-14 브레인 ㉮).
|
||||
table["seed_spray_choices"] = {"choices": list(SEED_SPRAY_GROUNDS)}
|
||||
# 준비공·사방공 — 못 서는 줄도 사유와 함께 남긴다(빈 표는 「빠뜨림」과 구별이 안 됨).
|
||||
structures = await _route_structures(project_id)
|
||||
table["preparation"] = build_preparation_table(
|
||||
@@ -163,6 +221,15 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
|
||||
# 임목파쇄 — 기본 꺼짐. 켠 프로젝트에서만 줄이 선다(확정 5차 5번).
|
||||
settings.get("wood_chipping_enabled"),
|
||||
settings.get("wood_chipping_volume_m3"),
|
||||
# 표토제거 대상 「노면」 면적 — 사면과 같은 측점 설계에서(2026-09-13 판정 「실무대로」).
|
||||
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)
|
||||
# ⚠ 금액에 바로 걸리는 값이라 「기본값으로 돌고 있음」을 응답에 실어 화면이 띄우게 한다.
|
||||
@@ -191,7 +258,7 @@ _GROUND_KIND_OF = {"ea_m3": "soil", "rr_m3": "ripping_rock", "br_m3": "blasting_
|
||||
|
||||
def _compacted_factor(settings: dict[str, Any]) -> dict[str, float]:
|
||||
"""갈래 칸 ↔ 다짐 환산계수 `C` — 프로젝트가 고른 값이 있으면 그것이 선다."""
|
||||
factors = earthwork_conversion_factors(settings)
|
||||
factors = mixed_conversion_factors(settings)
|
||||
return {key: float(factors[kind]["compacted"]) for key, kind in _GROUND_KIND_OF.items()}
|
||||
|
||||
|
||||
@@ -416,6 +483,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 [주]②).
|
||||
@@ -434,17 +503,39 @@ class QuantitySettingsBody(BaseModel):
|
||||
structure_trench_water: str | None = None
|
||||
# 표토 운반거리(m) — 별표2 가 요구하는 운반·적치의 밑수. 비면 그 줄이 막힌다.
|
||||
topsoil_haul_distance_m: float | None = None
|
||||
# 표토제거 대상 — "road_only" 는 노면만. `""` 는 기본(노면 + 절토, 별표2 문언)으로 되돌림.
|
||||
topsoil_target: str | None = None
|
||||
# 임목축적 등급 — "소림"·"중림"·"밀림"(품셈 9-21 [주]①). `""` 는 「안 정함」이다.
|
||||
stand_volume_class: str | None = None
|
||||
# 면고르기 갈래 — 절토면 토질 · 성토면 시공·토질(9-19-1 원문 표). `""` 는 「안 정함」.
|
||||
face_dressing_cut_class: str | None = None
|
||||
# 제근 굴착기 크기 — "0.2"·"0.7"(품셈 9-21 갈래). `""` 는 「안 정함」.
|
||||
root_removal_excavator_m3: str | None = None
|
||||
# 초류종자살포 비탈면 토질 — "일반"·"마사토"(품셈 5-24 잎). `""` 는 「안 정함」.
|
||||
seed_spray_ground: 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번). 켜면 줄이 서고, 부피를 넣으면 값이 선다.
|
||||
wood_chipping_enabled: bool | None = None
|
||||
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 는 「전부 기본값으로 되돌림」이라 통째로 갈아 끼운다.
|
||||
conversion_factors_override: dict[str, Any] | None = None
|
||||
# 도쟈 한계거리(m) — `None` 은 기본값(60 m). 종무대 20 m 보다 커야 한다(도쟈 몫이 사라짐).
|
||||
dozer_haul_limit_m: float | None = None
|
||||
|
||||
|
||||
#: `None` 이 「안 정함」을 뜻하는 칸 — 저장에서 **버리지 않고 그대로 덮어쓴다**.
|
||||
@@ -456,6 +547,10 @@ NULLABLE_SETTING_KEYS = (
|
||||
"rubble_base_thickness_m",
|
||||
"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",
|
||||
)
|
||||
|
||||
|
||||
@@ -482,10 +577,43 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
|
||||
for key in NULLABLE_SETTING_KEYS:
|
||||
if key in body.model_fields_set:
|
||||
values[key] = getattr(body, key)
|
||||
dozer_limit = values.get("dozer_haul_limit_m")
|
||||
free_haul = dict(EARTHWORK_HAUL_EQUIPMENT_LIMITS_M)["free_haul"] or 0.0
|
||||
if dozer_limit is not None and dozer_limit <= free_haul:
|
||||
# 조용히 기본값으로 돌리지 않는다 — 넣은 값이 안 쓰이는 줄 모른다.
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"status": "error",
|
||||
"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),
|
||||
("root_removal_excavator_m3", ROOT_REMOVAL_EXCAVATOR_SIZES),
|
||||
("seed_spray_ground", SEED_SPRAY_GROUNDS),
|
||||
):
|
||||
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"]
|
||||
values["topsoil_target"] = target if target == "road_only" else None
|
||||
if "conversion_factors_override" in values:
|
||||
# 아는 갈래·양수만 남긴다. 사유는 값이 있을 때만 따라간다(계산에는 안 쓴다).
|
||||
cleaned: dict[str, Any] = {}
|
||||
@@ -509,6 +637,7 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
|
||||
for name, method in values["rock_methods"].items()
|
||||
if method in ROCK_METHODS
|
||||
}
|
||||
before = {key: quantity_settings(root).get(key) for key in HAUL_PLAN_KEYS}
|
||||
try:
|
||||
# ⚠ 고른 값을 **되돌릴 수 있어야** 하는 칸은 통째로 갈아 끼운다 — 병합이면
|
||||
# 「안 정함」으로 되돌아가지 않는다(2026-09-07 화면에서 걸린 자리).
|
||||
@@ -519,7 +648,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",
|
||||
@@ -532,7 +665,16 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "산출 조건을 저장하지 못했습니다."},
|
||||
)
|
||||
return JSONResponse(content={"status": "success", "quantity": saved.get("quantity") or {}})
|
||||
after = quantity_settings(root)
|
||||
changed = any(before[key] != after.get(key) for key in HAUL_PLAN_KEYS)
|
||||
recomputed = await _recompute_haul_plan(project_id) if changed else False
|
||||
return JSONResponse(
|
||||
content={
|
||||
"status": "success",
|
||||
"quantity": saved.get("quantity") or {},
|
||||
"haul_plan_recomputed": recomputed,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _save_quantity(
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""산출 조건 [저장] 뒤 **운반계획 다시 세우기** — 2026-09-14 브레인 ②(지침 5장).
|
||||
|
||||
구성비·시공법·계수·도쟈 한계거리는 B06 유토곡선(운반계획)의 밑수다. B08 [저장]이 계획을 다시 안 세우면
|
||||
B06 을 다시 저장하기 전까지 토적표(새 계수) ↔ 운반표(옛 계획)가 갈린다(계수 칸은 전부터 같은 틈).
|
||||
⇒ 그 칸이 **바뀐 저장**만 B06 [저장]과 같은 서버 재계산을 부른다(무거워서 다른 칸 저장은 안 부름).
|
||||
본 라우터(`B08_Quantity_Router_Earthwork.py`)가 700줄에 닿아 이 파일로 뗌.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
from B06_Section.B06_Section_Repository import get_workflow_route_context
|
||||
from config.config_db import run_with_connection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: 운반계획의 밑수가 되는 산출 조건 칸.
|
||||
HAUL_PLAN_KEYS = (
|
||||
"rock_class_set",
|
||||
"rock_classes",
|
||||
"rock_ratios_pct",
|
||||
"rock_methods",
|
||||
"conversion_factors_override",
|
||||
"dozer_haul_limit_m",
|
||||
)
|
||||
|
||||
|
||||
async def recompute_haul_plan(project_id: UUID) -> bool:
|
||||
"""B06 [저장]과 같은 서버 재계산 — 못 하면 저장은 살리고 거짓(화면이 옛 계획임을 앎)."""
|
||||
from B06_Section.B06_Section_Server_Calc_Prebuild import recompute_server_side
|
||||
|
||||
try:
|
||||
context = await run_with_connection(get_workflow_route_context, project_id)
|
||||
route_id = int((context or {}).get("route_id") or 0)
|
||||
return bool(route_id) and await recompute_server_side(project_id, route_id) >= 0
|
||||
except Exception:
|
||||
logger.exception("B08 저장 뒤 운반계획 재계산 실패: project_id=%s", project_id)
|
||||
return False
|
||||
@@ -23,6 +23,7 @@ from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B06_Section.B06_Section_Repository import get_cross_section_designs
|
||||
@@ -56,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]] = []
|
||||
@@ -71,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} 가 이미 셈 — 중복 계상 방지"
|
||||
@@ -85,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))
|
||||
|
||||
|
||||
@@ -124,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:
|
||||
"""구조물 원단위와 자재총괄을 **한 응답**으로 낸다.
|
||||
@@ -141,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(
|
||||
@@ -161,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 = [
|
||||
@@ -194,6 +243,161 @@ async def get_material_summary(project_id: UUID) -> JSONResponse:
|
||||
return JSONResponse(content=body)
|
||||
|
||||
|
||||
@router.get("/{project_id}/quantity/structure-summary")
|
||||
async def get_structure_summary(project_id: UUID) -> JSONResponse:
|
||||
"""**구조물 집계표**(PLAN 2장) — 측점별 한 줄 · 종류별 표 · 칸마다 출처(자동·사용자·라이브러리).
|
||||
|
||||
⚠ 값을 셈하지 않음 — 정본 둘(`structures.json`·`pipe_points.json`)과 B06 관 연장을 읽기만.
|
||||
"""
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureSummary import (
|
||||
USER_CELLS_KEY,
|
||||
build_summary,
|
||||
pipe_lengths_from_designs,
|
||||
)
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import template_of
|
||||
from common_util.common_util_drainage_pipes import pipe_points_path_in, read_pipe_points_file
|
||||
|
||||
try:
|
||||
stored_path = await run_with_connection(get_project_storage_relative_path, project_id)
|
||||
project_root = resolve_stored_project_path(stored_path)
|
||||
revision, items = load_structures(project_root)
|
||||
points = read_pipe_points_file(pipe_points_path_in(Path(project_root)))
|
||||
except Exception:
|
||||
logger.exception("B08 구조물 집계표 — 정본 읽기 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "구조물 정본을 읽지 못했습니다."},
|
||||
)
|
||||
types = structure_type_map()
|
||||
embedded = project_templates(project_root)
|
||||
templates = {type_id: template_of(type_id, embedded) or {} for type_id in types}
|
||||
body = build_summary(
|
||||
[item.model_dump() for item in items],
|
||||
[point.as_dict() for point in points],
|
||||
types,
|
||||
templates,
|
||||
pipe_lengths_from_designs(await _designs(project_id)),
|
||||
quantity_settings(project_root).get(USER_CELLS_KEY) or {},
|
||||
)
|
||||
return JSONResponse(
|
||||
content={"status": "success", "revision": revision, "project_id": str(project_id), **body}
|
||||
)
|
||||
|
||||
|
||||
async def _designs(project_id: UUID) -> list[dict[str, Any]]:
|
||||
"""현재 노선의 저장된 횡단 설계. 못 읽으면 빈 목록 — 관 연장이 빈칸으로 섬(0 아님)."""
|
||||
try:
|
||||
context = await run_with_connection(get_workflow_route_context, project_id)
|
||||
route_id = int((context or {}).get("route_id") or 0)
|
||||
return await run_with_connection(get_cross_section_designs, route_id) if route_id else []
|
||||
except Exception:
|
||||
logger.exception("B08 구조물 집계표 — 횡단 설계 조회 실패: project_id=%s", project_id)
|
||||
return []
|
||||
|
||||
|
||||
class StructureSummaryEdit(BaseModel):
|
||||
"""집계표 칸 하나 — 빈 값(null·"")은 그 칸을 지움."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
id: str = Field(min_length=1, max_length=200)
|
||||
key: str = Field(min_length=1, max_length=100)
|
||||
value: float | str | None = None
|
||||
|
||||
|
||||
class StructureSummaryEditRequest(BaseModel):
|
||||
"""[저장] 한 번에 보내는 손 고침 — 읽어 간 구조물 판번호와 함께."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
base_revision: int = Field(ge=0)
|
||||
edits: list[StructureSummaryEdit] = Field(max_length=500)
|
||||
|
||||
|
||||
def _summary_error(status: int, message: str) -> JSONResponse:
|
||||
return JSONResponse(status_code=status, content={"status": "error", "message": message})
|
||||
|
||||
|
||||
@router.put("/{project_id}/quantity/structure-summary")
|
||||
async def put_structure_summary(
|
||||
project_id: UUID, payload: StructureSummaryEditRequest
|
||||
) -> JSONResponse:
|
||||
"""집계표 손 고침을 **정본에 바로 씀**(브레인 판정) + 산출 조건에 「손댄 칸」 표.
|
||||
|
||||
⚠ 덮개층을 두지 않음 — `structures.json`·`pipe_points.json` 이 곧 도면·수량의 한 값(지침 5장).
|
||||
⚠ 다 맞춰 본 뒤에 씀 — 한 칸이라도 틀리면 아무것도 안 씀(구조물 판번호가 어긋나도 409).
|
||||
⛔ 놓기 칸(자리·길이·높이·관경)은 거절 — 시·종점과 한 벌이라 B05 몫.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Repository import (
|
||||
StructureRevisionConflict,
|
||||
save_structures,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureSummary import (
|
||||
USER_CELLS_KEY,
|
||||
apply_edits,
|
||||
pipe_row_id,
|
||||
prune_marks,
|
||||
)
|
||||
from common_util.common_util_drainage_pipes import pipe_points_path_in
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from common_util.common_util_project_settings import save_section
|
||||
|
||||
try:
|
||||
stored_path = await run_with_connection(get_project_storage_relative_path, project_id)
|
||||
project_root = resolve_stored_project_path(stored_path)
|
||||
revision, items = load_structures(project_root)
|
||||
path = pipe_points_path_in(Path(project_root))
|
||||
document = json.loads(path.read_text(encoding="utf-8")) if path.is_file() else {}
|
||||
except Exception:
|
||||
logger.exception("B08 구조물 집계표 저장 — 정본 읽기 실패: project_id=%s", project_id)
|
||||
return _summary_error(500, "구조물 정본을 읽지 못했습니다.")
|
||||
if revision != payload.base_revision:
|
||||
return _summary_error(409, "그 사이 구조물이 바뀌었습니다 — 표를 다시 불러와 고칠 것.")
|
||||
|
||||
structures = [item.model_dump() for item in items]
|
||||
points = [point for point in document.get("points") or [] if isinstance(point, dict)]
|
||||
settings = quantity_settings(project_root)
|
||||
try:
|
||||
changed, marks = apply_edits(
|
||||
structures,
|
||||
points,
|
||||
structure_type_map(),
|
||||
[edit.model_dump() for edit in payload.edits],
|
||||
settings.get(USER_CELLS_KEY) or {},
|
||||
)
|
||||
instances = [StructureInstance.model_validate(item) for item in structures]
|
||||
except (LookupError, ValueError) as exc:
|
||||
return _summary_error(422, str(exc))
|
||||
|
||||
new_revision = revision
|
||||
pipe_ids = {pipe_row_id(point) for point in points}
|
||||
try:
|
||||
if any(row_id not in pipe_ids for row_id in changed):
|
||||
new_revision = await asyncio.to_thread(
|
||||
save_structures, project_root, instances, base_revision=revision
|
||||
)
|
||||
if any(row_id in pipe_ids for row_id in changed):
|
||||
# 관 지점 파일은 옵션만 바꿔 **그대로** 씀 — 노선 지문·좌표·다른 칸은 손대지 않음.
|
||||
await asyncio.to_thread(atomic_write_json, path, {**document, "points": points})
|
||||
except StructureRevisionConflict:
|
||||
return _summary_error(409, "그 사이 구조물이 바뀌었습니다 — 표를 다시 불러와 고칠 것.")
|
||||
except ValueError as exc:
|
||||
return _summary_error(422, str(exc))
|
||||
await asyncio.to_thread(
|
||||
save_section,
|
||||
project_root,
|
||||
"quantity",
|
||||
{USER_CELLS_KEY: prune_marks(marks, structures, points)},
|
||||
replace_keys=[USER_CELLS_KEY],
|
||||
)
|
||||
return JSONResponse(
|
||||
content={"status": "success", "revision": new_revision, "changed_rows": len(changed)}
|
||||
)
|
||||
|
||||
|
||||
async def project_haul_inputs(project_id: UUID) -> dict[str, Any]:
|
||||
"""유토곡선(B06)이 받아야 할 **구조물 몫** — 채집석 공제 · 구조물 잔토.
|
||||
|
||||
@@ -204,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,
|
||||
@@ -247,39 +451,22 @@ 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(
|
||||
structures,
|
||||
names,
|
||||
await _section_modes(project_id),
|
||||
modes,
|
||||
await _ground_types(project_id),
|
||||
settings.get("rubble_base_thickness_m"),
|
||||
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"),
|
||||
@@ -307,6 +494,15 @@ 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,
|
||||
# 제근 굴착기 크기(0.2·0.7) — 비면 뿌리뽑기 줄이 입력 사유로 막힘(제안 0.7 은 칸 곁에만).
|
||||
root_removal_excavator_m3=settings.get("root_removal_excavator_m3") or None,
|
||||
# 초류종자살포 비탈면 토질 — 부모 5-24 의 잎을 고름. 비면 그 줄이 입력 사유로 막힘(09-14 ㉮).
|
||||
seed_spray_ground=settings.get("seed_spray_ground") or None,
|
||||
# 구조물도 양식 일위대가로 셀 장 — 그 구조물은 호표 `AX-ST` 줄 하나로(PLAN 6장 ②).
|
||||
priced_sheets=_priced_sheets(project_root, unit_table, modes, settings),
|
||||
)
|
||||
handoff["summary"] = summarize(handoff)
|
||||
handoff["skipped_structures"] = skipped
|
||||
@@ -314,6 +510,74 @@ async def get_handoff(project_id: UUID) -> JSONResponse:
|
||||
return JSONResponse(content=handoff)
|
||||
|
||||
|
||||
def _priced_sheets(
|
||||
project_root: str,
|
||||
unit_table: dict[str, Any],
|
||||
section_modes: dict[float, str] | None,
|
||||
settings: dict[str, Any],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""양식 일위대가로 셀 구조물도 장 — 구조물도 탭과 **같은 접기**(`build_standard_sheets` →
|
||||
`apply_templates`)로 세움. 인계와 내역 금액이 이 한 목록을 봄."""
|
||||
from B08_Quantity.B08_Quantity_Engine_StructurePriceLink import priced_sheets
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureSheet import build_standard_sheets
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import apply_templates, template_of
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import ROWS_KEY
|
||||
|
||||
embedded = project_templates(project_root)
|
||||
payload = build_standard_sheets(unit_table, section_modes)
|
||||
apply_templates(payload, settings, embedded)
|
||||
sheets = payload.get("sheets") or []
|
||||
templates = {
|
||||
type_id: template
|
||||
for type_id in {str(sheet.get("type_id") or "") for sheet in sheets}
|
||||
if (template := template_of(type_id, embedded))
|
||||
}
|
||||
return priced_sheets(sheets, templates, settings.get(ROWS_KEY))
|
||||
|
||||
|
||||
async def structure_bill_prices(project_id: UUID, build: Any) -> dict[str, dict[str, Any]]:
|
||||
"""B09 내역이 부름 — 구조물도 호표 금액. **B09 가 넘긴 단가표**로 B08 일위대가 엔진을 돌림.
|
||||
|
||||
⚠ 금액 정본은 B09 — 여기서 단가표를 따로 짓지 않음. 수동 단가(프로젝트 값)도 입력으로 씀.
|
||||
⚠ 인계(`get_handoff`)와 **같은 전개**를 한 번 더 돎 — 두 요청이라 한 벌을 못 나눔.
|
||||
"""
|
||||
# ponytail: 인계와 전개를 두 번 돎 — 느려지면 인계 응답에 장 목록을 실어 한 번으로.
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import available_templates
|
||||
from B08_Quantity.B08_Quantity_Engine_StructurePriceLink import price_sheets
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import MANUAL_KEY, ROWS_KEY, with_rows
|
||||
from B09_Estimation.B09_Estimation_UnitPrice import find_variant_code
|
||||
|
||||
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, await _designs(project_id))
|
||||
settings = quantity_settings(project_root)
|
||||
modes = await _section_modes(project_id)
|
||||
unit_table = build_unit_table(
|
||||
structures,
|
||||
names,
|
||||
modes,
|
||||
await _ground_types(project_id),
|
||||
settings.get("rubble_base_thickness_m"),
|
||||
structure_formulas=settings.get("structure_formula_overrides"),
|
||||
structure_templates=project_templates(project_root),
|
||||
)
|
||||
entries = _priced_sheets(project_root, unit_table, modes, settings)
|
||||
if not entries:
|
||||
return {}
|
||||
row_edits = settings.get(ROWS_KEY) or {}
|
||||
library = [
|
||||
with_rows(template, row_edits.get(template.get("type_id")))
|
||||
for template in available_templates(project_root)
|
||||
]
|
||||
return price_sheets(
|
||||
entries,
|
||||
build.book,
|
||||
lambda code, value: find_variant_code(code, value, build),
|
||||
library,
|
||||
settings.get(MANUAL_KEY) or {},
|
||||
)
|
||||
|
||||
|
||||
def _pipe_table(
|
||||
project_root: str,
|
||||
pipe_lengths: list[dict[str, Any]],
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
"""B08 라이브러리 — STmate 출력 엑셀에서 **고정형 항목 뽑아 넣기** (PLAN 4장 · 2026-09-14 브레인 판정).
|
||||
|
||||
두 걸음:
|
||||
① [읽기] 파일을 올리면 읽은 호표 수·구성 줄 수·못 읽은 사유를 돌려줌 — **아무것도 안 씀**.
|
||||
사용자가 수를 보고 넣을지 정함(판정 「읽은 뒤 수를 보이고 묻는 걸음」).
|
||||
② [넣기] 같은 파일 + 고른 호표 차례 + 우리 구조물 종류 → 서버가 **파일을 다시 읽어** 개인 단에 씀.
|
||||
⚠ 브라우저가 보낸 줄을 받아 적지 않음(CLAUDE.md 5장) · 개인 단만(판정 Ⓗ) · 종류당 하나라 같은 종류 내 것은
|
||||
덮어씀(판정 Ⓐ) · 종류는 사용자가 고름 — 이름으로 자동으로 안 붙임(판정 Ⓒ).
|
||||
③ [복제해서 내 것으로](PLAN 4장) — 기본·회사 단 항목을 개인 단에 베낌. 프로젝트 작업본은 안 바꿈.
|
||||
(구조물도 라우터가 700줄에 닿아 개인 단으로 넣는 창구를 이 파일에 모음.)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
from typing import Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
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"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class LibraryCloneRequest(BaseModel):
|
||||
"""베낄 항목 — 단과 코드(이름은 겹칠 수 있음). 개인 단 것은 이미 내 것이라 안 받음."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
type_id: str = Field(min_length=1, max_length=100)
|
||||
tier: Literal["received", "company", "program"]
|
||||
code: str = Field(pattern=r"^AX-ST-[0-9a-f]{8}$")
|
||||
|
||||
|
||||
@router.put("/{project_id}/quantity/structure-sheets/library/clone")
|
||||
async def clone_library_item(
|
||||
project_id: UUID,
|
||||
payload: LibraryCloneRequest,
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
) -> JSONResponse:
|
||||
"""③ 기본·회사 항목을 **로그인한 사람 개인 단**에 베낌 — 가져와 고친 뒤 [내 라이브러리에 저장]할 길을 한 번에."""
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import (
|
||||
find_item,
|
||||
save_item_personal,
|
||||
tier_dirs,
|
||||
)
|
||||
|
||||
dirs = tier_dirs(session.get("company_id"), session.get("user_id"))
|
||||
folder = dirs.get("personal")
|
||||
if folder is None:
|
||||
return _error(403, "개인 라이브러리는 회사에 속한 사용자만 씁니다.")
|
||||
item = await asyncio.to_thread(find_item, dirs, payload.tier, payload.code)
|
||||
if item is None or item.get("type_id") != payload.type_id:
|
||||
return _error(404, "베낄 항목을 찾지 못했습니다.")
|
||||
body = {k: v for k, v in item.items() if k not in ("code", "imported_from", "library_tier")}
|
||||
body["cloned_from"] = {"tier": payload.tier, "code": payload.code, "name": item.get("name")}
|
||||
code = await asyncio.to_thread(save_item_personal, folder, body)
|
||||
return JSONResponse(content={"status": "success", "code": code, "name": item.get("name")})
|
||||
|
||||
|
||||
async def _company_members(company_id: int) -> list[dict[str, Any]]:
|
||||
"""같은 회사 구성원 — B01 구성원 저장소를 읽기만(시험이 갈아 끼움)."""
|
||||
from B01_Dashboard.B01_Dashboard_Repository_Members import list_company_members
|
||||
|
||||
return await list_company_members(company_id)
|
||||
|
||||
|
||||
@router.get("/{project_id}/quantity/structure-sheets/library/colleagues")
|
||||
async def list_library_colleagues(
|
||||
project_id: UUID, session: dict[str, Any] = Depends(verify_session)
|
||||
) -> JSONResponse:
|
||||
"""④ 보낼 동료 — 같은 회사에서 나를 뺀 사람(이름만)."""
|
||||
company_id = session.get("company_id")
|
||||
if company_id is None:
|
||||
return _error(403, "회사에 속한 사용자만 보냅니다.")
|
||||
members = await _company_members(int(company_id))
|
||||
colleagues = [
|
||||
{"id": m["id"], "name": m.get("name") or m.get("email") or ""}
|
||||
for m in members
|
||||
if m["id"] != session.get("user_id")
|
||||
]
|
||||
return JSONResponse(content={"status": "success", "colleagues": colleagues})
|
||||
|
||||
|
||||
class LibraryShareRequest(BaseModel):
|
||||
"""내 개인 단 항목 하나를 같은 회사 동료에게."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
type_id: str = Field(min_length=1, max_length=100)
|
||||
code: str = Field(pattern=r"^AX-ST-[0-9a-f]{8}$")
|
||||
to_user_id: int
|
||||
|
||||
|
||||
@router.put("/{project_id}/quantity/structure-sheets/library/share")
|
||||
async def share_library_item(
|
||||
project_id: UUID,
|
||||
payload: LibraryShareRequest,
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
) -> JSONResponse:
|
||||
"""④ [동료에게 보내기] — 받는 쪽 「받음」 단에 복사(PLAN 4장 공유 · 브레인 판정 ①)."""
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import (
|
||||
RECEIVED_SUBDIR,
|
||||
find_item,
|
||||
share_item,
|
||||
tier_dirs,
|
||||
)
|
||||
|
||||
company_id = session.get("company_id")
|
||||
dirs = tier_dirs(company_id, session.get("user_id"))
|
||||
if "personal" not in dirs:
|
||||
return _error(403, "회사에 속한 사용자만 보냅니다.")
|
||||
if payload.to_user_id == session.get("user_id"):
|
||||
return _error(400, "나에게는 보내지 않습니다.")
|
||||
members = {m["id"]: m for m in await _company_members(int(company_id))}
|
||||
target = members.get(payload.to_user_id)
|
||||
if target is None:
|
||||
return _error(404, "같은 회사에서 받는 사람을 찾지 못했습니다.")
|
||||
item = await asyncio.to_thread(find_item, dirs, "personal", payload.code)
|
||||
if item is None or item.get("type_id") != payload.type_id:
|
||||
return _error(404, "보낼 내 항목을 찾지 못했습니다.")
|
||||
me = members.get(session.get("user_id")) or {}
|
||||
sender = {"user_id": session.get("user_id"), "name": me.get("name") or me.get("email") or ""}
|
||||
to_folder = dirs["personal"].parents[1] / str(payload.to_user_id) / RECEIVED_SUBDIR
|
||||
code = await asyncio.to_thread(share_item, item, to_folder, sender)
|
||||
return JSONResponse(content={"status": "success", "code": code, "to": target.get("name") or ""})
|
||||
@@ -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
|
||||
)
|
||||
@@ -303,6 +316,7 @@ async def get_structure_library(
|
||||
"status": "success",
|
||||
"items": await asyncio.to_thread(list_items, dirs, type_id),
|
||||
"current": {"code": current.get("code"), "imported_from": current.get("imported_from")},
|
||||
"can_publish": {tier: _publish_dir(session, tier) is not None for tier in PUBLISH},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -313,7 +327,7 @@ class LibraryImportRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
type_id: str = Field(min_length=1, max_length=100)
|
||||
tier: Literal["personal", "company", "program"]
|
||||
tier: Literal["personal", "received", "company", "program"]
|
||||
code: str = Field(pattern=r"^AX-ST-[0-9a-f]{8}$")
|
||||
|
||||
|
||||
@@ -323,9 +337,9 @@ async def put_structure_library_import(
|
||||
payload: LibraryImportRequest,
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
) -> JSONResponse:
|
||||
"""고른 항목을 **프로젝트 작업본에 박고** 그 종류의 고친 식을 비움.
|
||||
"""고른 항목을 **프로젝트 작업본에 박고** 그 종류의 고친 식·줄 조합·수동 단가를 비움.
|
||||
|
||||
⚠ 고친 식을 비우는 까닭 — 새 양식과 줄 차례가 안 맞을 수 있음. 묻는 것은 화면 몫.
|
||||
⚠ 비우는 까닭 — 셋 다 줄 차례에 묶였는데 새 양식과 차례가 안 맞을 수 있음. 묻는 것은 화면 몫.
|
||||
"""
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import (
|
||||
find_item,
|
||||
@@ -333,6 +347,7 @@ async def put_structure_library_import(
|
||||
tier_dirs,
|
||||
)
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import OVERRIDES_KEY
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import MANUAL_KEY, ROWS_KEY
|
||||
from common_util.common_util_project_settings import quantity_settings, save_section
|
||||
|
||||
project_root = await _project_root(project_id)
|
||||
@@ -346,23 +361,22 @@ async def put_structure_library_import(
|
||||
content={"status": "error", "message": "가져올 양식 항목을 찾지 못했습니다."},
|
||||
)
|
||||
await asyncio.to_thread(import_item, project_root, item, payload.tier)
|
||||
current = quantity_settings(project_root).get(OVERRIDES_KEY) or {}
|
||||
cleared = len(current.get(payload.type_id) or {})
|
||||
if cleared:
|
||||
rest = {key: rows for key, rows in current.items() if key != payload.type_id}
|
||||
await asyncio.to_thread(
|
||||
save_section,
|
||||
project_root,
|
||||
"quantity",
|
||||
{OVERRIDES_KEY: rest},
|
||||
replace_keys=[OVERRIDES_KEY],
|
||||
)
|
||||
settings = quantity_settings(project_root)
|
||||
current = {key: settings.get(key) or {} for key in (OVERRIDES_KEY, ROWS_KEY, MANUAL_KEY)}
|
||||
touched = [key for key, value in current.items() if payload.type_id in value]
|
||||
if touched:
|
||||
rest = {
|
||||
key: {k: v for k, v in current[key].items() if k != payload.type_id} for key in touched
|
||||
}
|
||||
await asyncio.to_thread(save_section, project_root, "quantity", rest, replace_keys=touched)
|
||||
return JSONResponse(
|
||||
content={
|
||||
"status": "success",
|
||||
"code": payload.code,
|
||||
"imported_from": payload.tier,
|
||||
"cleared_formulas": cleared,
|
||||
"cleared_formulas": len(current[OVERRIDES_KEY].get(payload.type_id) or {}),
|
||||
"cleared_unit_price_rows": payload.type_id in current[ROWS_KEY],
|
||||
"cleared_manual_prices": len(current[MANUAL_KEY].get(payload.type_id) or {}),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -381,15 +395,33 @@ def _no_personal() -> JSONResponse:
|
||||
)
|
||||
|
||||
|
||||
#: 발행 단 — 회사는 마스터·시스템 관리자, 프로그램 기본은 시스템 관리자(2026-09-14 브레인 승인).
|
||||
PUBLISH = ("company", "program")
|
||||
|
||||
|
||||
def _publish_dir(session: dict[str, Any], tier: str) -> Path | None:
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import program_library_dir, tier_dirs
|
||||
|
||||
admin = session.get("role") == "SYSTEM_ADMIN"
|
||||
if tier == "program":
|
||||
return program_library_dir() if admin else None
|
||||
if tier == "company" and (admin or session.get("is_master")):
|
||||
return tier_dirs(session.get("company_id"), None).get("company")
|
||||
return _personal_dir(session) if tier == "personal" else None
|
||||
|
||||
|
||||
class LibrarySaveRequest(BaseModel):
|
||||
"""[내 라이브러리에 저장] — 어느 장의 양식을 쓸지."""
|
||||
"""[내 라이브러리에 저장]·[발행] — 어느 장의 양식을 어느 단에 쓸지(같은 모양)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
sheet_key: str
|
||||
tier: Literal["personal", "company", "program"] = "personal"
|
||||
name: str | None = Field(default=None, max_length=200) # 이름표(10-A ⑫) · 비면 양식 이름
|
||||
|
||||
|
||||
@router.put("/{project_id}/quantity/structure-sheets/library/personal")
|
||||
@router.put("/{project_id}/quantity/structure-sheets/library/publish")
|
||||
async def put_structure_library_personal(
|
||||
project_id: UUID,
|
||||
payload: LibrarySaveRequest,
|
||||
@@ -399,16 +431,18 @@ async def put_structure_library_personal(
|
||||
|
||||
⚠ 프로젝트 작업본은 안 바꿈 — 반대 방향(작업본 → 개인 단)이라(판정 Ⓑ).
|
||||
"""
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import (
|
||||
project_templates,
|
||||
save_personal,
|
||||
)
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import project_templates, save_personal
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import OVERRIDES_KEY, template_of
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import ROWS_KEY
|
||||
from common_util.common_util_project_settings import quantity_settings
|
||||
|
||||
folder = _personal_dir(session)
|
||||
folder = _publish_dir(session, payload.tier)
|
||||
if folder is None:
|
||||
return _no_personal()
|
||||
if payload.tier == "personal":
|
||||
return _no_personal()
|
||||
return JSONResponse(
|
||||
status_code=403, content={"status": "error", "message": "발행 권한이 없습니다."}
|
||||
)
|
||||
project_root = await _project_root(project_id)
|
||||
if project_root is None:
|
||||
return _not_found()
|
||||
@@ -421,8 +455,12 @@ async def put_structure_library_personal(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "양식이 있는 구조물도 장을 찾지 못했습니다."},
|
||||
)
|
||||
overrides = (quantity_settings(project_root).get(OVERRIDES_KEY) or {}).get(type_id)
|
||||
code = await asyncio.to_thread(save_personal, folder, template, overrides)
|
||||
settings = quantity_settings(project_root)
|
||||
overrides = (settings.get(OVERRIDES_KEY) or {}).get(type_id)
|
||||
# ⛔ 수동 단가(`MANUAL_KEY`)는 안 넘김 — 프로젝트의 값(브레인 판정).
|
||||
rows = (settings.get(ROWS_KEY) or {}).get(type_id)
|
||||
tier, name = payload.tier, payload.name
|
||||
code = await asyncio.to_thread(save_personal, folder, template, overrides, rows, tier, name)
|
||||
return JSONResponse(content={"status": "success", "code": code, "edited": len(overrides or {})})
|
||||
|
||||
|
||||
@@ -447,51 +485,193 @@ async def _price_build(project_id: UUID) -> Any:
|
||||
return await _build_for(project_id)
|
||||
|
||||
|
||||
async def _sheet_template(
|
||||
project_id: UUID, project_root: str, sheet_key: str
|
||||
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
|
||||
"""장 하나와 그 양식(프로젝트에 박힌 것 → 프로그램 기본)."""
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import project_templates
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import template_of
|
||||
|
||||
sheets = (await _sheets_of(project_id, project_root)).get("sheets") or []
|
||||
picked = next((s for s in sheets if s.get("key") == sheet_key), None)
|
||||
if picked is None:
|
||||
return None, None
|
||||
return picked, template_of(str(picked.get("type_id") or ""), project_templates(project_root))
|
||||
|
||||
|
||||
def _no_sheet() -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "그 구조물도 장을 찾지 못했습니다."},
|
||||
)
|
||||
|
||||
|
||||
async def _price_build_or_error(project_id: UUID) -> tuple[Any, JSONResponse | None]:
|
||||
try:
|
||||
return await _price_build(project_id), None
|
||||
except Exception:
|
||||
logger.exception("B08 구조물도 일위대가 — 단가표 조립 실패: project_id=%s", project_id)
|
||||
return None, JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "단가표를 조립하지 못했습니다."},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/quantity/structure-sheets/unit-price")
|
||||
async def get_structure_unit_price(project_id: UUID, sheet_key: str) -> JSONResponse:
|
||||
"""장 하나의 하단 **일위대가 표**(미리보기) — 줄 조합은 양식, 단가는 B09 단가표.
|
||||
"""장 하나의 하단 **일위대가 표**(미리보기) — 줄 조합은 양식(+고친 것), 단가는 B09 단가표.
|
||||
|
||||
⚠ 장 조회와 창구를 나눔 — 단가표 첫 조립이 십여 초라 장 조회를 늦추지 않게.
|
||||
⚠ 값을 여기서 정본으로 적지 않음 — 보이기만(내역 금액은 B09 가 셈).
|
||||
⚠ `editor` — 화면이 고칠 줄 조합·양식 줄·수동 단가. 값(금액)은 늘 이 서버 계산만 씀.
|
||||
"""
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import (
|
||||
available_templates,
|
||||
project_templates,
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import available_templates
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import (
|
||||
MANUAL_KEY,
|
||||
ROWS_KEY,
|
||||
unit_price_table,
|
||||
with_rows,
|
||||
)
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import template_of
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import unit_price_table
|
||||
from B09_Estimation.B09_Estimation_UnitPrice import find_variant_code
|
||||
from common_util.common_util_project_settings import quantity_settings
|
||||
|
||||
project_root = await _project_root(project_id)
|
||||
if project_root is None:
|
||||
return _not_found()
|
||||
sheets = (await _sheets_of(project_id, project_root)).get("sheets") or []
|
||||
picked = next((s for s in sheets if s.get("key") == sheet_key), None)
|
||||
picked, template = await _sheet_template(project_id, project_root, sheet_key)
|
||||
if picked is None:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "그 구조물도 장을 찾지 못했습니다."},
|
||||
)
|
||||
template = template_of(str(picked.get("type_id") or ""), project_templates(project_root))
|
||||
if not template or not template.get("unit_price"):
|
||||
return _no_sheet()
|
||||
if not template:
|
||||
return JSONResponse(content={"status": "success", "unit_price": None})
|
||||
try:
|
||||
build = await _price_build(project_id)
|
||||
except Exception:
|
||||
logger.exception("B08 구조물도 일위대가 — 단가표 조립 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "단가표를 조립하지 못했습니다."},
|
||||
)
|
||||
build, error = await _price_build_or_error(project_id)
|
||||
if error:
|
||||
return error
|
||||
settings = quantity_settings(project_root)
|
||||
row_edits = settings.get(ROWS_KEY) or {}
|
||||
type_id = str(template.get("type_id") or "")
|
||||
manual = (settings.get(MANUAL_KEY) or {}).get(type_id) or {}
|
||||
effective = with_rows(template, row_edits.get(type_id))
|
||||
# 하위 양식도 그 종류에서 고친 줄 조합으로 풂 — 수동 단가는 맨 윗 표만.
|
||||
library = [
|
||||
with_rows(t, row_edits.get(t.get("type_id"))) for t in available_templates(project_root)
|
||||
]
|
||||
table = await asyncio.to_thread(
|
||||
unit_price_table,
|
||||
template,
|
||||
effective,
|
||||
picked,
|
||||
build.book,
|
||||
lambda code, value: find_variant_code(code, value, build),
|
||||
available_templates(project_root),
|
||||
library,
|
||||
manual,
|
||||
)
|
||||
return JSONResponse(content={"status": "success", "unit_price": table})
|
||||
editor = {
|
||||
"rows": effective["unit_price"]["rows"],
|
||||
"default_rows": (template.get("unit_price") or {}).get("rows") or [],
|
||||
"edited": type_id in row_edits,
|
||||
"manual_prices": manual,
|
||||
}
|
||||
return JSONResponse(content={"status": "success", "unit_price": table, "editor": editor})
|
||||
|
||||
|
||||
class UnitPriceRowEdit(BaseModel):
|
||||
"""일위대가 줄 하나 — 수량은 원단위 줄(`from_row`) 또는 박힌 값, 코드는 고르개가 채움."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
seq: int = Field(ge=1)
|
||||
name: str | None = Field(default=None, max_length=200)
|
||||
spec: str | None = Field(default=None, max_length=200)
|
||||
unit: str | None = Field(default=None, max_length=20)
|
||||
from_row: int | None = Field(default=None, ge=1)
|
||||
quantity: float | None = None
|
||||
ref_code: str | None = Field(default=None, max_length=100)
|
||||
work_item_code: str | None = Field(default=None, max_length=100)
|
||||
variant_from: str | None = Field(default=None, max_length=50)
|
||||
sub_vars: dict[str, float | str] | None = None
|
||||
|
||||
|
||||
class ManualPrice(BaseModel):
|
||||
"""수동 단가 — 단위당 3분할과 출처. 넣은 날짜는 서버가 붙임."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
material: float = Field(default=0, ge=0)
|
||||
labor: float = Field(default=0, ge=0)
|
||||
expense: float = Field(default=0, ge=0)
|
||||
source: str = Field(default="", max_length=200)
|
||||
|
||||
|
||||
class UnitPriceEditRequest(BaseModel):
|
||||
"""한 화면에서 고친 일위대가 — **저장 자리는 둘**(줄 조합 = 양식+프로젝트 · 단가 = 프로젝트)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
sheet_key: str
|
||||
rows: list[UnitPriceRowEdit] = Field(max_length=100)
|
||||
manual_prices: dict[str, ManualPrice] = Field(default_factory=dict)
|
||||
|
||||
|
||||
@router.put("/{project_id}/quantity/structure-sheets/unit-price")
|
||||
async def put_structure_unit_price(project_id: UUID, payload: UnitPriceEditRequest) -> JSONResponse:
|
||||
"""고친 줄 조합·수동 단가를 산출 조건에 저장. 금액은 화면이 다시 조회해 서버 계산으로 받음."""
|
||||
from datetime import date
|
||||
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import (
|
||||
MANUAL_KEY,
|
||||
ROWS_KEY,
|
||||
save_manual,
|
||||
save_rows,
|
||||
)
|
||||
from common_util.common_util_project_settings import quantity_settings, save_section
|
||||
|
||||
rows = [row.model_dump(exclude_none=True) for row in payload.rows]
|
||||
if len({row["seq"] for row in rows}) != len(rows):
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content={"status": "error", "message": "일위대가 줄 차례가 겹칩니다."},
|
||||
)
|
||||
project_root = await _project_root(project_id)
|
||||
if project_root is None:
|
||||
return _not_found()
|
||||
picked, template = await _sheet_template(project_id, project_root, payload.sheet_key)
|
||||
if picked is None or template is None:
|
||||
return _no_sheet()
|
||||
type_id = str(template.get("type_id") or "")
|
||||
settings = quantity_settings(project_root)
|
||||
merged_rows, changed = save_rows(settings.get(ROWS_KEY) or {}, type_id, template, rows)
|
||||
prices = {seq: price.model_dump() for seq, price in payload.manual_prices.items()}
|
||||
merged_manual = save_manual(
|
||||
settings.get(MANUAL_KEY) or {}, type_id, rows, prices, date.today().isoformat()
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
save_section,
|
||||
project_root,
|
||||
"quantity",
|
||||
{ROWS_KEY: merged_rows, MANUAL_KEY: merged_manual},
|
||||
replace_keys=[ROWS_KEY, MANUAL_KEY],
|
||||
)
|
||||
return JSONResponse(
|
||||
content={
|
||||
"status": "success",
|
||||
"changed_rows": changed,
|
||||
"edited": type_id in merged_rows,
|
||||
"manual_prices": len(merged_manual.get(type_id) or {}),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/quantity/structure-sheets/price-search")
|
||||
async def get_structure_price_search(
|
||||
project_id: UUID, q: str, kind: Literal["work", "resource"] = "work"
|
||||
) -> JSONResponse:
|
||||
"""고르개 — 이 프로젝트 단가표에서 품셈(일위대가)·자원(자재·노임·중기)을 낱말로 찾음."""
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import search_titles
|
||||
|
||||
build, error = await _price_build_or_error(project_id)
|
||||
if error:
|
||||
return error
|
||||
items = await asyncio.to_thread(search_titles, build.book, q[:100], kind)
|
||||
return JSONResponse(content={"status": "success", "items": items})
|
||||
|
||||
|
||||
@router.get("/{project_id}/quantity/structure-sheets")
|
||||
|
||||
@@ -78,6 +78,8 @@ export interface QuantitySettings {
|
||||
rock_methods?: Record<string, string>;
|
||||
/** 자재별 관급/사급 — 표 안에서 줄마다 고른 값. */
|
||||
material_supply?: Record<string, { supply: string; install_by: string | null }>;
|
||||
/** 자재별 할증률(%) — 표값 이내에서 고른 값만. `null`·없음이면 표값. */
|
||||
material_surcharge?: Record<string, number | null>;
|
||||
/** 콘크리트 타설 방식. `null`·없음이면 **아직 안 정한 것**이고 화면이 기본값 안내를 띄운다. */
|
||||
concrete_placing_method?: string | null;
|
||||
/** 표토 두께(m). `null`·없음이면 **안 정한 것**이라 표토제거 줄이 「근거 없음」으로 선다. */
|
||||
@@ -92,16 +94,39 @@ export interface QuantitySettings {
|
||||
structure_trench_water?: string | null;
|
||||
/** 표토 운반거리(m) — 별표2 가 요구하는 운반·적치의 밑수. 비면 그 줄이 막힌다. */
|
||||
topsoil_haul_distance_m?: number | null;
|
||||
/** 도쟈 한계거리(m) — `null`·없음이면 기본값(서버 정본 60 m)이 선다. */
|
||||
dozer_haul_limit_m?: number | null;
|
||||
/** 부대시설 개소 — `{항목키: 개소}`. ⚠ 산식으로 만들지 않는다(확정 13). */
|
||||
ancillary_counts?: Record<string, number | null>;
|
||||
/** 임목축적 등급 — `"소림"`·`"중림"`·`"밀림"`(품셈 9-21 [주]①). 본수가 아니라 축적이다. */
|
||||
stand_volume_class?: string | null;
|
||||
/** 제근 굴착기 크기 — `"0.2"`·`"0.7"`(서버 선택지 안) · 비면 「안 정함」. */
|
||||
root_removal_excavator_m3?: string | null;
|
||||
/** 초류종자살포 비탈면 토질 — `"일반"`·`"마사토"`(5-24 잎) · 비면 「안 정함」. */
|
||||
seed_spray_ground?: 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;
|
||||
/** 규준틀 개소당 재료 — `{자재명: 수량}`. 비우면 제안값(실무 관측)이 선다. */
|
||||
frame_material?: Record<string, number | null>;
|
||||
/** 임목파쇄 — 기본 꺼짐(확정 5차 5번). 켜야 줄이 선다. */
|
||||
wood_chipping_enabled?: boolean | null;
|
||||
/** 파쇄 부피(㎥) — 켜도 이 값이 없으면 줄만 서고 사유가 남는다. */
|
||||
wood_chipping_volume_m3?: number | null;
|
||||
/** 노체다짐 — 기본 꺼짐(9-16-2 [주]⑤ 조건부). 켜면 토공집계에 별도 줄. */
|
||||
subgrade_compaction_enabled?: boolean | null;
|
||||
/** 임목폐기물 조사값 넷 — 비면 톤이 안 선다. */
|
||||
tree_waste?: Record<string, number | null>;
|
||||
/** 처리단가(원/톤) — 수동 · 미확정. */
|
||||
tree_waste_unit_price_krw_per_ton?: number | null;
|
||||
/** 폐기물 분리발주 — 기본 아님. */
|
||||
waste_separate_order?: boolean | null;
|
||||
/** 뿌리 산정법 — 없음이면 분배비 15/85 · `"root_ball"` 은 뿌리분 체적 × 1,300. */
|
||||
tree_waste_root_method?: string | null;
|
||||
}
|
||||
|
||||
/** 갈래 하나의 「무엇을 골랐나」. 서버 `earthwork_conversion_choices` 와 짝이다. */
|
||||
@@ -139,8 +164,26 @@ export interface EarthworkTable {
|
||||
settings?: QuantitySettings;
|
||||
/** 갈래별 토량환산계수 선택 상태 — 산출 조건 패널이 그린다. */
|
||||
conversion_factor_choices?: Record<string, ConversionFactorChoice>;
|
||||
/** 면고르기 갈래 선택지 — 품셈 9-19-1 원문 표 두 벌(서버 한 곳). 화면은 그대로 보임. */
|
||||
face_dressing_choices?: { cut: string[]; fill: string[] };
|
||||
/** 초류종자살포 비탈면 토질 선택지(5-24 잎 둘) — 서버 한 곳 · 제안값 없음. */
|
||||
seed_spray_choices?: { choices: string[] };
|
||||
/** 제근 굴착기 크기 선택지·제안(회색 · [제안값 넣기]) — 서버 한 곳. */
|
||||
root_removal_excavator_choices?: {
|
||||
choices: string[];
|
||||
suggested?: { value: string; basis: string };
|
||||
};
|
||||
/** 품셈 암종별 범위(안내용). 정의처가 서버라 내려받아 보인다. */
|
||||
conversion_factor_pumsem_ranges?: PumsemRange[];
|
||||
/** 도쟈 한계거리 — 지금 값·기본값·근거(서버가 정본). 종무대 20 m 는 규정이라 값만 보인다. */
|
||||
haul_limit_choice?: {
|
||||
value: number;
|
||||
default: number;
|
||||
chosen: boolean;
|
||||
basis: string;
|
||||
free_haul_m: number;
|
||||
free_haul_basis: string;
|
||||
};
|
||||
/** 근거 사전 — ⚠ **개발환경에서만** 실려 온다. 운영에서는 칸 자체가 없다. */
|
||||
provenance?: ProvenancePayload;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface MaterialRow {
|
||||
unit: string;
|
||||
net_amount: number;
|
||||
surcharge_pct: number | null;
|
||||
/** 할증률표 값 = 고를 수 있는 천장(품셈 1-3-1 「표의 값 이내」). `null` 이면 칸이 안 섬. */
|
||||
surcharge_cap?: number | null;
|
||||
total_amount: number;
|
||||
supply: string;
|
||||
supply_label: string;
|
||||
@@ -241,9 +243,50 @@ export interface SupplyChoice {
|
||||
export interface MaterialGridOptions {
|
||||
/** 저장 전 변경분 — 고른 값은 여기 쌓이고 [저장]에서만 정본으로 간다. */
|
||||
choices: Record<string, SupplyChoice>;
|
||||
/** 할증률 고른 값 `{이름 규격: % | null}` — 산출 조건 `material_surcharge`. 안 주면 칸 없이 글자만.
|
||||
* `null` = 표값으로 되돌림. 0 ~ 표값만 받음(서버도 한 번 더 거름). */
|
||||
surcharge?: Record<string, number | null>;
|
||||
onChange: () => void;
|
||||
}
|
||||
|
||||
/** 할증률 칸 — 표값(상한) 안에서만. 범위를 넘으면 칸이 빨갛게 되고 값을 안 받음. */
|
||||
function surchargeCell(
|
||||
row: MaterialRow,
|
||||
key: string,
|
||||
picks: Record<string, number | null>,
|
||||
onChange: () => void,
|
||||
): HTMLTableCellElement {
|
||||
const cap = row.surcharge_cap as number;
|
||||
const td = document.createElement("td");
|
||||
const input = document.createElement("input");
|
||||
input.type = "number";
|
||||
input.min = "0";
|
||||
input.max = String(cap);
|
||||
input.step = "0.1";
|
||||
input.className = "b08-grid__select";
|
||||
input.style.width = "4.5em";
|
||||
const picked = picks[key];
|
||||
// 안 고른 칸은 **비워 두고 회색 제안값**(지금 붙는 표값)만 — 기본값을 값처럼 채우지 않음(폼 ② 규칙).
|
||||
input.value = picked === null || picked === undefined ? "" : String(picked);
|
||||
input.placeholder = String(row.surcharge_pct ?? cap);
|
||||
input.title = `표값 ${cap}% 이내(품셈 1-3-1) · 비우면 표값`;
|
||||
input.addEventListener("change", () => {
|
||||
const text = input.value.trim();
|
||||
const value = Number(text);
|
||||
if (text !== "" && !(Number.isFinite(value) && value >= 0 && value <= cap)) {
|
||||
input.setCustomValidity(`0 ~ ${cap}% 만 받음`);
|
||||
input.reportValidity();
|
||||
return;
|
||||
}
|
||||
input.setCustomValidity("");
|
||||
picks[key] = text === "" ? null : value;
|
||||
td.classList.add("is-changed");
|
||||
onChange();
|
||||
});
|
||||
td.append(input);
|
||||
return td;
|
||||
}
|
||||
|
||||
/** 표 안의 고르는 칸. 바꾼 줄은 **표시가 남는다** — 무엇을 만졌는지 보여야 한다. */
|
||||
function choiceCell(
|
||||
value: string,
|
||||
@@ -337,7 +380,12 @@ export function renderMaterialGrid(
|
||||
tr.append(textCell(row.unit, "b08-grid__unit"));
|
||||
tr.append(textCell(num(row.net_amount, 2)));
|
||||
// 미확보는 빈칸이 아니라 「-」 — 빈칸이면 0 % 로 오해된다.
|
||||
tr.append(textCell(row.surcharge_pct === null ? "-" : num(row.surcharge_pct, 0)));
|
||||
// 표값이 있는 줄은 「이내」로 고를 칸(초안 `surcharge` 가 이어졌을 때만).
|
||||
tr.append(
|
||||
options?.surcharge && typeof row.surcharge_cap === "number"
|
||||
? surchargeCell(row, key, options.surcharge, options.onChange)
|
||||
: textCell(row.surcharge_pct === null ? "-" : num(row.surcharge_pct, 0)),
|
||||
);
|
||||
tr.append(textCell(num(row.total_amount, 2)));
|
||||
if (options) {
|
||||
// 관급/사급은 **자재마다 갈리는 발주 결정**이라 줄에서 고른다(2026-09-07 확정).
|
||||
|
||||
@@ -39,6 +39,12 @@ import {
|
||||
type MaterialResponse,
|
||||
} 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 { appendRootRemovalFields } from "./B08_Quantity_UI_Side_RootRemoval";
|
||||
import { appendSeedSprayField } from "./B08_Quantity_UI_Side_SeedSpray";
|
||||
import { appendBenchCutFields } from "./B08_Quantity_UI_Side_BenchCut";
|
||||
|
||||
/** locale 헬퍼 */
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
@@ -92,6 +98,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,
|
||||
@@ -101,14 +108,31 @@ async function saveQuantitySettings(projectId: string, draft: DraftSettings): Pr
|
||||
spoil_site_distance_m: draft.spoil_site_distance_m,
|
||||
structure_trench_water: draft.structure_trench_water,
|
||||
topsoil_haul_distance_m: draft.topsoil_haul_distance_m,
|
||||
// `""` 는 기본(노면 + 절토)으로 되돌림 — 서버가 None 으로 둔다.
|
||||
topsoil_target: draft.topsoil_target,
|
||||
stand_volume_class: draft.stand_volume_class,
|
||||
root_removal_excavator_m3: draft.root_removal_excavator_m3,
|
||||
seed_spray_ground: draft.seed_spray_ground,
|
||||
// 면고르기 — 갈래 `""`·면적 `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 는 「전부 기본값으로 되돌림」이다.
|
||||
conversion_factors_override: conversionOverridePayload(draft.conversion_factors),
|
||||
// 도쟈 한계거리 — `null` 도 보낸다(기본값으로 되돌리는 길).
|
||||
dozer_haul_limit_m: draft.dozer_haul_limit_m,
|
||||
}),
|
||||
},
|
||||
);
|
||||
@@ -281,19 +305,41 @@ interface DraftSettings {
|
||||
structure_trench_water: string;
|
||||
// 표토 운반거리(m) — 별표2 가 요구하는 운반·적치의 밑수. `null` 은 「안 정함」.
|
||||
topsoil_haul_distance_m: number | null;
|
||||
// 표토제거 대상 — `""` 기본(노면 + 절토, 별표2 문언) · "road_only" 노면만(설계자 선택).
|
||||
topsoil_target: string;
|
||||
// 부대시설 개소 — `{항목키: 개소}`. ⚠ 산식으로 만들지 않는다(확정 13).
|
||||
ancillary_counts: Record<string, number | null>;
|
||||
// 임목축적 등급 — "소림"·"중림"·"밀림". ⚠ 본수가 아니라 축적이다(품셈 9-21 [주]①).
|
||||
stand_volume_class: string;
|
||||
// 제근 굴착기 크기 — "0.2"·"0.7"(서버 선택지) · `""` 는 「안 정함」. 칸은 `_Side_RootRemoval`.
|
||||
root_removal_excavator_m3: string;
|
||||
// 초류종자살포 비탈면 토질 — "일반"·"마사토"(5-24 잎) · `""` 는 「안 정함」. 칸은 `_Side_SeedSpray`.
|
||||
seed_spray_ground: string;
|
||||
// 면고르기 — 갈래 둘(서버 선택지) · 면적 덮어쓰기 둘(`null` = 파종 면적). 칸은 `_Side_FaceDressing`.
|
||||
face_dressing_cut_class: string;
|
||||
face_dressing_fill_class: string;
|
||||
face_dressing_fill_area_m2: number | null;
|
||||
face_dressing_cut_area_m2: number | null;
|
||||
// 규준틀 개소당 재료 — 비우면 **제안값(실무 관측)**이 선다. 값이 아니라 「고칠 수 있음」이 요점.
|
||||
frame_material: Record<string, number | null>;
|
||||
// 임목파쇄 — **기본 꺼짐**(확정 5차 5번). 켜야 줄이 선다. 근주이식은 칸 자체가 없다.
|
||||
wood_chipping_enabled: boolean;
|
||||
wood_chipping_volume_m3: number | null;
|
||||
// 노체다짐 — **기본 꺼짐**(9-16-2 [주]⑤ 조건부). 켜면 토공집계에 별도 줄(수량 = 성토량).
|
||||
subgrade_compaction_enabled: boolean;
|
||||
// 임목폐기물 — 조사값 넷 · 수동 처리단가(미확정) · 분리발주(기본 아님).
|
||||
tree_waste: Record<string, number | null>;
|
||||
tree_waste_unit_price_krw_per_ton: number | null;
|
||||
waste_separate_order: boolean;
|
||||
tree_waste_root_method: string;
|
||||
// 자재별 관급/사급 — 표 안에서 줄마다 고른 값.
|
||||
material_supply: Record<string, SupplyChoice>;
|
||||
// 자재별 할증률(%) — 표값 이내에서 고른 값만 · `null` 은 표값.
|
||||
material_surcharge: Record<string, number | null>;
|
||||
// 갈래별 토량환산계수(다짐) — `compacted` 가 `null` 이면 「안 고름」이라 기본값이 선다.
|
||||
conversion_factors: Record<string, FactorDraft>;
|
||||
// 도쟈 한계거리(m) — `null` 은 기본값(서버 정본 60 m)이 선다.
|
||||
dozer_haul_limit_m: number | null;
|
||||
dirty: boolean;
|
||||
}
|
||||
|
||||
@@ -402,6 +448,30 @@ function buildQuantitySidePanel(
|
||||
);
|
||||
if (factorFields) panel.append(factorFields);
|
||||
|
||||
// ── 운반장비 거리 경계 — 도쟈 한계거리는 설계 조건이라 칸으로(2026-09-13 판정) ──
|
||||
// ⚠ 기본값과 근거를 **서버가 준 그대로** 옆에 보인다 — 「이 값이 어디서 왔나」가 화면에 있어야 함.
|
||||
const haul = table?.haul_limit_choice;
|
||||
if (haul) {
|
||||
panel.append(field(L("B08_Quantity_Side_HaulLimits"), ""));
|
||||
const dozer = optionalNumberField(
|
||||
L("B08_Quantity_Side_DozerLimit_Label"),
|
||||
draft.dozer_haul_limit_m,
|
||||
"5",
|
||||
(value) => {
|
||||
draft.dozer_haul_limit_m = value;
|
||||
draft.dirty = true;
|
||||
},
|
||||
);
|
||||
const input = dozer.querySelector("input");
|
||||
if (input) input.placeholder = String(haul.default);
|
||||
panel.append(dozer);
|
||||
panel.append(hintRow(`${L("B08_Quantity_Factor_Default")} ${haul.default} m — ${haul.basis}`));
|
||||
panel.append(
|
||||
hintRow(`${L("B08_Quantity_Side_FreeHaul")} ${haul.free_haul_m} m — ${haul.free_haul_basis}`),
|
||||
);
|
||||
panel.append(hintRow(L("B08_Quantity_Side_HaulLimits_Reach")));
|
||||
}
|
||||
|
||||
// ── 지반 구성비 — 갈래 수는 프로젝트 세트가 정한다(코드에 안 박음, PLAN 8-13) ──
|
||||
const classes = [...(table?.summary?.rock_classes ?? [])];
|
||||
// ⚠ 비율을 아직 안 넣었으면 집계가 **「암」 한 줄**로 나온다(갈래로 안 갈림). 그 줄에도
|
||||
@@ -459,10 +529,31 @@ function buildQuantitySidePanel(
|
||||
}
|
||||
}
|
||||
|
||||
// ── 표토 두께 — 정해야 준비공 표토제거 줄이 선다(품셈 9-15 [주]② 의 「T : 표토두께(m)」) ──
|
||||
// ── 초류종자살포 토질 · 면고르기 — 밑수가 파종 면적이라 반영률 바로 밑. 선택지는 서버 목록 그대로.
|
||||
const sideHelpers = { field, optionalNumberField, selectField, hintRow };
|
||||
appendSeedSprayField(panel, draft, table?.seed_spray_choices, sideHelpers);
|
||||
appendFaceDressingFields(panel, draft, table?.face_dressing_choices, sideHelpers);
|
||||
|
||||
// ── 표토 두께 — 표토 운반 부피(제거 ㎡ × T)에 씀 · 제거 줄은 ㎡ 라 안 곱함(2026-09-13 「실무대로」) ──
|
||||
// ⚠ 2026-09-08 ㉘ 자기 감사: 서버·엔진은 이 값을 받고 있었는데 **화면에 넣을 칸이 없었다.**
|
||||
// 「죽은 칸」(넣어도 안 쓰임)의 반대 짝이다 — 쓰이는데 넣을 데가 없던 자리.
|
||||
panel.append(field(L("B08_Quantity_Side_Topsoil"), ""));
|
||||
// 대상 — 기본은 법 문언(노면 + 절토). 영월 한 건이 노면만이라 3배 차이 → 설계자가 고름(2026-09-14).
|
||||
panel.append(
|
||||
selectField(
|
||||
L("B08_Quantity_Side_TopsoilTarget_Label"),
|
||||
draft.topsoil_target,
|
||||
[
|
||||
{ value: "", label: L("B08_Quantity_TopsoilTarget_RoadAndCut") },
|
||||
{ value: "road_only", label: L("B08_Quantity_TopsoilTarget_RoadOnly") },
|
||||
],
|
||||
(value) => {
|
||||
draft.topsoil_target = value;
|
||||
draft.dirty = true;
|
||||
},
|
||||
),
|
||||
);
|
||||
panel.append(hintRow(L("B08_Quantity_Side_TopsoilTarget_Hint")));
|
||||
panel.append(
|
||||
optionalNumberField(
|
||||
L("B08_Quantity_Side_Topsoil_Label"),
|
||||
@@ -480,18 +571,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")));
|
||||
// 층따기 칸 · KDS 근거 줄 · [제안값 넣기] — 누른 때만 채움(교본 6-4 「설계도서에 명시」).
|
||||
appendBenchCutFields(panel, draft, { field, optionalNumberField, selectField, hintRow });
|
||||
panel.append(
|
||||
optionalNumberField(
|
||||
L("B08_Quantity_Side_Rubble_Label"),
|
||||
@@ -561,6 +642,12 @@ function buildQuantitySidePanel(
|
||||
),
|
||||
);
|
||||
panel.append(hintRow(L("B08_Quantity_Side_StandVolume_Hint")));
|
||||
appendRootRemovalFields(panel, draft, table?.root_removal_excavator_choices, {
|
||||
field,
|
||||
optionalNumberField,
|
||||
selectField,
|
||||
hintRow,
|
||||
});
|
||||
|
||||
// ── 부대시설 개소 — ⚠ **산식으로 만들지 않는다**(확정 13). 넣어야 줄이 선다 ──
|
||||
panel.append(field(L("B08_Quantity_Side_Ancillary"), ""));
|
||||
@@ -629,6 +716,27 @@ function buildQuantitySidePanel(
|
||||
);
|
||||
panel.append(hintRow(L("B08_Quantity_Side_Chipping_Hint")));
|
||||
|
||||
// ── 노체다짐 — ⚠ **기본 꺼짐**(2026-09-13 판정). 9-16-2 [주]⑤ 조건부라 켜야 토공집계에 줄이 선다.
|
||||
panel.append(field(L("B08_Quantity_Side_SubgradeCompaction"), ""));
|
||||
panel.append(
|
||||
selectField(
|
||||
L("B08_Quantity_SubgradeCompaction_Label"),
|
||||
draft.subgrade_compaction_enabled ? "on" : "",
|
||||
[
|
||||
{ value: "", label: L("B08_Quantity_Chipping_Off") },
|
||||
{ value: "on", label: L("B08_Quantity_Chipping_On") },
|
||||
],
|
||||
(value) => {
|
||||
draft.subgrade_compaction_enabled = value === "on";
|
||||
draft.dirty = true;
|
||||
},
|
||||
),
|
||||
);
|
||||
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(
|
||||
@@ -831,6 +939,11 @@ function buildQuantityBody(
|
||||
},
|
||||
// 구조물도 — 제원 조합 하나 = 한 장(PLAN 3장). 스스로 받아 오므로 표를 넘기지 않음.
|
||||
// ⚠ 탭 차례 바꾸기·원단위 탭 흡수는 PLAN 5장 몫 — 여기서는 원단위 앞에 붙이기만 함.
|
||||
// 구조물 집계표 — 측점별 한 줄(PLAN 2장). 구조물도의 앞 사슬이라 그 바로 앞에 둠.
|
||||
{
|
||||
label: L("B08_Quantity_Tab_StructureSummary"),
|
||||
build: () => renderStructureSummary(projectId),
|
||||
},
|
||||
{ label: L("B08_Quantity_Tab_StructureSheet"), build: () => renderStructureSheets(projectId) },
|
||||
{
|
||||
label: L("B08_Quantity_Tab_UnitQuantity"),
|
||||
@@ -845,6 +958,7 @@ function buildQuantityBody(
|
||||
material.material,
|
||||
{
|
||||
choices: draft.material_supply,
|
||||
surcharge: draft.material_surcharge,
|
||||
onChange: () => {
|
||||
draft.dirty = true;
|
||||
},
|
||||
@@ -916,14 +1030,30 @@ export async function renderB08Quantity(root: HTMLElement): Promise<void> {
|
||||
spoil_site_distance_m: (stored.spoil_site_distance_m as number | null) ?? null,
|
||||
structure_trench_water: (stored.structure_trench_water as string) ?? "",
|
||||
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) ?? "",
|
||||
root_removal_excavator_m3: (stored.root_removal_excavator_m3 as string) ?? "",
|
||||
seed_spray_ground: (stored.seed_spray_ground as string) ?? "",
|
||||
face_dressing_cut_class: (stored.face_dressing_cut_class as string) ?? "",
|
||||
face_dressing_fill_class: (stored.face_dressing_fill_class as string) ?? "",
|
||||
face_dressing_fill_area_m2: (stored.face_dressing_fill_area_m2 as number | null) ?? null,
|
||||
face_dressing_cut_area_m2: (stored.face_dressing_cut_area_m2 as number | null) ?? null,
|
||||
frame_material: { ...((stored.frame_material ?? {}) as Record<string, number | null>) },
|
||||
wood_chipping_enabled: Boolean(stored.wood_chipping_enabled),
|
||||
wood_chipping_volume_m3: (stored.wood_chipping_volume_m3 as number | null) ?? null,
|
||||
subgrade_compaction_enabled: Boolean(stored.subgrade_compaction_enabled),
|
||||
tree_waste: { ...((stored.tree_waste ?? {}) as Record<string, number | null>) },
|
||||
tree_waste_unit_price_krw_per_ton:
|
||||
(stored.tree_waste_unit_price_krw_per_ton as number | null) ?? null,
|
||||
waste_separate_order: Boolean(stored.waste_separate_order),
|
||||
tree_waste_root_method: (stored.tree_waste_root_method as string) ?? "",
|
||||
ancillary_counts: {
|
||||
...((stored.ancillary_counts ?? {}) as Record<string, number | null>),
|
||||
},
|
||||
material_supply: { ...((stored.material_supply ?? {}) as Record<string, SupplyChoice>) },
|
||||
material_surcharge: {
|
||||
...((stored.material_surcharge ?? {}) as Record<string, number | null>),
|
||||
},
|
||||
// ⚠ 저장분에 있는 갈래만 담는다 — 기본값을 복사해 넣으면 「안 고름」이 사라진다.
|
||||
conversion_factors: Object.fromEntries(
|
||||
Object.entries(stored.conversion_factors_override ?? {}).map(([kind, entry]) => [
|
||||
@@ -934,6 +1064,7 @@ export async function renderB08Quantity(root: HTMLElement): Promise<void> {
|
||||
},
|
||||
]),
|
||||
),
|
||||
dozer_haul_limit_m: (stored.dozer_haul_limit_m as number | null) ?? null,
|
||||
dirty: false,
|
||||
};
|
||||
const reload = (): void => {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/* =============================================================================
|
||||
* B08_Quantity_UI_Side_BenchCut.ts
|
||||
* 산출 조건 「층따기 길이」 칸 · 근거 줄 · [제안값 넣기] 단추 (2026-09-14 브레인 판정).
|
||||
*
|
||||
* 페이지 본문(`B08_Quantity_UI_Page.ts`)이 700줄을 넘어 이 칸 묶음을 이 파일로 뺌(CLAUDE.md 4장).
|
||||
* ⚠ 자동으로 안 채움 — 임도기술교본 6-4 「나. 층따기」 「설계도서에 명시되어 있는 높이와 폭으로
|
||||
* 하고 … 현장감독관과 협의하여 조정」 ⇒ 값은 설계자가 정함. 단추를 **누른 때만** KDS 44 30 00
|
||||
* 도로토공 2.3.4 (4) ② 표준치수(토사 최소 높이 500 mm)가 칸에 들어감 · 캐시에만, [저장]에서 정본.
|
||||
* ========================================================================== */
|
||||
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import type { SideFieldHelpers } from "./B08_Quantity_UI_Side_TreeWaste";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
/** KDS 44 30 00 도로토공 2.3.4 (4) ② — 기초지반이 토사인 경우 최소 높이(임도 지반이 대개 토사). */
|
||||
const KDS_SOIL_MIN_HEIGHT_M = 0.5;
|
||||
|
||||
export interface BenchCutDraft {
|
||||
bench_cut_depth_m: number | null;
|
||||
dirty: boolean;
|
||||
}
|
||||
|
||||
export function appendBenchCutFields(
|
||||
panel: HTMLElement,
|
||||
draft: BenchCutDraft,
|
||||
h: SideFieldHelpers,
|
||||
): void {
|
||||
const row = h.optionalNumberField(
|
||||
L("B08_Quantity_Side_BenchCut_Label"),
|
||||
draft.bench_cut_depth_m,
|
||||
"0.01",
|
||||
(value) => {
|
||||
draft.bench_cut_depth_m = value;
|
||||
draft.dirty = true;
|
||||
},
|
||||
);
|
||||
panel.append(row, h.hintRow(L("B08_Quantity_Side_BenchCut_Hint")));
|
||||
// 층따기 표준치수 — KDS 근거 표시(2026-09-14 브레인 정정 · 「제안값」 경고 아님).
|
||||
panel.append(h.hintRow(L("B08_Quantity_Side_BenchCut_Suggest")));
|
||||
panel.append(
|
||||
createButton({
|
||||
label: L("B08_Quantity_Side_BenchCut_Fill"),
|
||||
variant: "ghost",
|
||||
onClick: () => {
|
||||
const input = row.querySelector("input");
|
||||
if (input) input.value = String(KDS_SOIL_MIN_HEIGHT_M);
|
||||
draft.bench_cut_depth_m = KDS_SOIL_MIN_HEIGHT_M;
|
||||
draft.dirty = true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/* =============================================================================
|
||||
* B08_Quantity_UI_Side_FaceDressing.ts
|
||||
* 산출 조건 「면고르기」 구획 — 절토면 토질 · 성토면 시공·토질 고르기 둘 + 면적 덮어쓰기 둘
|
||||
* (2026-09-14 브레인 판정 Ⓐ~Ⓓ · 서버 ee9368ec).
|
||||
*
|
||||
* 페이지 본문(`B08_Quantity_UI_Page.ts`)이 700줄을 넘어 새 칸을 이 파일로 뺌(CLAUDE.md 4장).
|
||||
* ⚠ 선택지는 **서버가 내려준 목록 그대로**(`face_dressing_choices`) — 화면에 다시 적지 않음(두 벌 금지).
|
||||
* ⚠ 스스로 안 고름(판정 Ⓒ) — 첫 보기가 「안 정함」이고 비면 내역 줄이 입력 사유로 섬. 성토면 제안은
|
||||
* 회색 근거 + [제안값 넣기]를 **누른 때만**(메인 976e5fd9 `fill_suggested`).
|
||||
* ⚠ 면적은 화면이 셈하지 않음 — 비우면 서버가 파종 면적(초류종자살포 × 반영률)을 씀.
|
||||
* ========================================================================== */
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
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[];
|
||||
/** 성토면 제안(서버 한 곳 · 실무 관측) — 회색으로 보이고 [제안값 넣기]를 **누른 때만** 들어감. */
|
||||
fill_suggested?: { value: string; basis: 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;
|
||||
}),
|
||||
);
|
||||
const fillRow = pick(
|
||||
"B08_Quantity_FaceDressing_Fill",
|
||||
draft.face_dressing_fill_class,
|
||||
choices.fill,
|
||||
(v) => {
|
||||
draft.face_dressing_fill_class = v;
|
||||
},
|
||||
);
|
||||
panel.append(fillRow);
|
||||
// 성토면 제안 — 칸은 비워 둔 채 회색 근거 + 단추(층따기와 같은 모양). 비우면 「안 정함」 그대로.
|
||||
const suggested = choices.fill_suggested;
|
||||
if (suggested?.value) {
|
||||
panel.append(
|
||||
h.hintRow(
|
||||
`${L("B08_Quantity_FaceDressing_Suggest")} ${suggested.value} — ${suggested.basis}`,
|
||||
),
|
||||
);
|
||||
panel.append(
|
||||
createButton({
|
||||
label: L("B08_Quantity_FaceDressing_FillSuggested"),
|
||||
variant: "ghost",
|
||||
onClick: () => {
|
||||
const select = fillRow.querySelector("select");
|
||||
if (select) select.value = suggested.value;
|
||||
draft.face_dressing_fill_class = suggested.value;
|
||||
draft.dirty = true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
panel.append(
|
||||
area("B08_Quantity_FaceDressing_FillArea", draft.face_dressing_fill_area_m2, (v) => {
|
||||
draft.face_dressing_fill_area_m2 = v;
|
||||
}),
|
||||
);
|
||||
panel.append(
|
||||
area("B08_Quantity_FaceDressing_CutArea", draft.face_dressing_cut_area_m2, (v) => {
|
||||
draft.face_dressing_cut_area_m2 = v;
|
||||
}),
|
||||
);
|
||||
panel.append(h.hintRow(L("B08_Quantity_Side_FaceDressing_Hint")));
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/* =============================================================================
|
||||
* B08_Quantity_UI_Side_RootRemoval.ts
|
||||
* 산출 조건 「제근 굴착기 크기」 칸 — 임목축적 등급 바로 밑(PLAN 10-A · 9-21 크기 칸 서버 64a52df6).
|
||||
*
|
||||
* 페이지 본문(`B08_Quantity_UI_Page.ts`)이 700줄을 넘어 새 칸을 이 파일로 뺌(CLAUDE.md 4장).
|
||||
* ⚠ 선택지·제안은 **서버가 내려준 그대로**(`root_removal_excavator_choices`) — 화면에 다시 적지 않음.
|
||||
* ⚠ 스스로 안 고름 — 첫 보기가 「안 정함」이고 비면 뿌리뽑기 줄이 입력 사유로 섬. 제안 0.7 은
|
||||
* 회색 근거 + [제안값 넣기]를 **누른 때만**(면고르기 성토면과 같은 모양).
|
||||
* ========================================================================== */
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
export interface RootRemovalChoices {
|
||||
choices: string[];
|
||||
suggested?: { value: string; basis: string };
|
||||
}
|
||||
|
||||
export function appendRootRemovalFields(
|
||||
panel: HTMLElement,
|
||||
draft: { root_removal_excavator_m3: string; dirty: boolean },
|
||||
choices: RootRemovalChoices | undefined,
|
||||
h: SideFieldHelpers,
|
||||
): void {
|
||||
if (!choices) return;
|
||||
const row = h.selectField(
|
||||
L("B08_Quantity_Side_RootRemoval_Label"),
|
||||
draft.root_removal_excavator_m3,
|
||||
[
|
||||
{ value: "", label: L("B08_Quantity_RootRemoval_Unset") },
|
||||
...choices.choices.map((size) => ({ value: size, label: `${size}㎥` })),
|
||||
],
|
||||
(picked) => {
|
||||
draft.root_removal_excavator_m3 = picked;
|
||||
draft.dirty = true;
|
||||
},
|
||||
);
|
||||
panel.append(row);
|
||||
const suggested = choices.suggested;
|
||||
if (!suggested?.value) return;
|
||||
panel.append(
|
||||
h.hintRow(`${L("B08_Quantity_RootRemoval_Suggest")} ${suggested.value}㎥ — ${suggested.basis}`),
|
||||
createButton({
|
||||
label: L("B08_Quantity_RootRemoval_FillSuggested"),
|
||||
variant: "ghost",
|
||||
onClick: () => {
|
||||
const select = row.querySelector("select");
|
||||
if (select) select.value = suggested.value;
|
||||
draft.root_removal_excavator_m3 = suggested.value;
|
||||
draft.dirty = true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/* =============================================================================
|
||||
* B08_Quantity_UI_Side_SeedSpray.ts
|
||||
* 산출 조건 「초류종자살포 비탈면 토질」 칸 — 반영률 밑 · 면고르기 위(2026-09-14 브레인 ㉮).
|
||||
*
|
||||
* 매핑이 부모 공종(5-24 씨앗뿜어붙이기 · 갈래 고르기형)을 가리켜 잎(일반·마사토)을 고를 칸이 없었음
|
||||
* → 금액이 영영 안 섬. 이 칸이 잎을 고름(서버 매핑 `leaf_from`).
|
||||
* 페이지 본문(`B08_Quantity_UI_Page.ts`)이 700줄을 넘어 새 칸을 이 파일로 뺌(CLAUDE.md 4장).
|
||||
* ⚠ 선택지는 **서버가 내려준 그대로**(`seed_spray_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 function appendSeedSprayField(
|
||||
panel: HTMLElement,
|
||||
draft: { seed_spray_ground: string; dirty: boolean },
|
||||
choices: { choices: string[] } | undefined,
|
||||
h: SideFieldHelpers,
|
||||
): void {
|
||||
if (!choices) return;
|
||||
panel.append(
|
||||
h.selectField(
|
||||
L("B08_Quantity_Side_SeedSpray_Label"),
|
||||
draft.seed_spray_ground,
|
||||
[
|
||||
{ value: "", label: L("B08_Quantity_SeedSpray_Unset") },
|
||||
...choices.choices.map((name) => ({ value: name, label: name })),
|
||||
],
|
||||
(picked) => {
|
||||
draft.seed_spray_ground = picked;
|
||||
draft.dirty = true;
|
||||
},
|
||||
),
|
||||
h.hintRow(L("B08_Quantity_Side_SeedSpray_Hint")),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/* =============================================================================
|
||||
* B08_Quantity_UI_Side_TreeWaste.ts
|
||||
* 산출 조건 「임목폐기물」 구획 — 현장 조사값 넷 · 수동 처리단가 · 분리발주(2026-09-14 판정).
|
||||
*
|
||||
* 페이지 본문(`B08_Quantity_UI_Page.ts`)이 700줄을 넘어 새 칸을 이 파일로 뺌(CLAUDE.md 4장).
|
||||
* ⚠ 톤은 서버가 셈 — 여기는 값만 캐시에 쌓고 [저장]에서 정본으로 감(5장).
|
||||
* ========================================================================== */
|
||||
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
export interface TreeWasteDraft {
|
||||
tree_waste: Record<string, number | null>;
|
||||
tree_waste_unit_price_krw_per_ton: number | null;
|
||||
waste_separate_order: boolean;
|
||||
/** 뿌리 산정법 — `""` 분배비 15/85(기본) · "root_ball" 뿌리분 체적 × 1,300. */
|
||||
tree_waste_root_method: string;
|
||||
dirty: boolean;
|
||||
}
|
||||
|
||||
/** 페이지의 칸 만들기 도우미 — 같은 모양을 쓰려고 받아 씀. */
|
||||
export interface SideFieldHelpers {
|
||||
field(label: string, value: string): HTMLElement;
|
||||
optionalNumberField(
|
||||
label: string,
|
||||
value: number | null,
|
||||
step: string,
|
||||
onInput: (value: number | null) => void,
|
||||
): HTMLElement;
|
||||
selectField(
|
||||
label: string,
|
||||
value: string,
|
||||
options: { value: string; label: string }[],
|
||||
onChange: (value: string) => void,
|
||||
): HTMLElement;
|
||||
hintRow(text: string): HTMLElement;
|
||||
}
|
||||
|
||||
/** 조사값 칸 — 서버 `TREE_WASTE_INPUTS` 와 같은 키. */
|
||||
const INPUTS: [string, keyof typeof ui_locales, string][] = [
|
||||
["stems_per_1000m2", "B08_Quantity_TreeWaste_Stems", "1"],
|
||||
["dbh_cm", "B08_Quantity_TreeWaste_Dbh", "0.1"],
|
||||
["height_m", "B08_Quantity_TreeWaste_Height", "0.1"],
|
||||
["unit_weight_kg_m3", "B08_Quantity_TreeWaste_UnitWeight", "10"],
|
||||
];
|
||||
|
||||
/** 수동 단가 칸 — 값이 있으면 빨간 테두리(미확정). */
|
||||
function markManual(row: HTMLElement, value: number | null): void {
|
||||
const input = row.querySelector("input");
|
||||
if (input) input.style.outline = value ? "2px solid var(--color-danger, #d9534f)" : "";
|
||||
}
|
||||
|
||||
export function appendTreeWasteFields(
|
||||
panel: HTMLElement,
|
||||
draft: TreeWasteDraft,
|
||||
h: SideFieldHelpers,
|
||||
): void {
|
||||
panel.append(h.field(L("B08_Quantity_Side_TreeWaste"), ""));
|
||||
for (const [key, label, step] of INPUTS) {
|
||||
panel.append(
|
||||
h.optionalNumberField(L(label), draft.tree_waste[key] ?? null, step, (value) => {
|
||||
draft.tree_waste[key] = value;
|
||||
draft.dirty = true;
|
||||
}),
|
||||
);
|
||||
}
|
||||
panel.append(h.hintRow(L("B08_Quantity_Side_TreeWaste_Hint")));
|
||||
|
||||
// 뿌리 — 지침 부록1 이 두 방법을 둠. 뿌리분 직경은 흉고직경과 갈라 따로 받음(지침 표기가 모호).
|
||||
panel.append(
|
||||
h.selectField(
|
||||
L("B08_Quantity_TreeWaste_RootMethod"),
|
||||
draft.tree_waste_root_method,
|
||||
[
|
||||
{ value: "", label: L("B08_Quantity_TreeWaste_RootRatio") },
|
||||
{ value: "root_ball", label: L("B08_Quantity_TreeWaste_RootBall") },
|
||||
],
|
||||
(value) => {
|
||||
draft.tree_waste_root_method = value;
|
||||
draft.dirty = true;
|
||||
},
|
||||
),
|
||||
);
|
||||
panel.append(
|
||||
h.optionalNumberField(
|
||||
L("B08_Quantity_TreeWaste_RootBallDiameter"),
|
||||
draft.tree_waste["root_ball_diameter_m"] ?? null,
|
||||
"0.01",
|
||||
(value) => {
|
||||
draft.tree_waste["root_ball_diameter_m"] = value;
|
||||
draft.dirty = true;
|
||||
},
|
||||
),
|
||||
);
|
||||
panel.append(h.hintRow(L("B08_Quantity_TreeWaste_Root_Hint")));
|
||||
|
||||
const price = h.optionalNumberField(
|
||||
L("B08_Quantity_TreeWaste_Price"),
|
||||
draft.tree_waste_unit_price_krw_per_ton,
|
||||
"1000",
|
||||
(value) => {
|
||||
draft.tree_waste_unit_price_krw_per_ton = value;
|
||||
draft.dirty = true;
|
||||
markManual(price, value);
|
||||
},
|
||||
);
|
||||
markManual(price, draft.tree_waste_unit_price_krw_per_ton);
|
||||
panel.append(price);
|
||||
const priceHint = h.hintRow(L("B08_Quantity_TreeWaste_Price_Hint"));
|
||||
priceHint.classList.add("b08-quantity__hint--warn");
|
||||
panel.append(priceHint);
|
||||
|
||||
panel.append(
|
||||
h.selectField(
|
||||
L("B08_Quantity_TreeWaste_Separate"),
|
||||
draft.waste_separate_order ? "on" : "",
|
||||
[
|
||||
{ value: "", label: L("B08_Quantity_TreeWaste_Separate_Off") },
|
||||
{ value: "on", label: L("B08_Quantity_TreeWaste_Separate_On") },
|
||||
],
|
||||
(value) => {
|
||||
draft.waste_separate_order = value === "on";
|
||||
draft.dirty = true;
|
||||
},
|
||||
),
|
||||
);
|
||||
panel.append(h.hintRow(L("B08_Quantity_TreeWaste_Separate_Hint")));
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/* =============================================================================
|
||||
* B08_Quantity_UI_StructureFigure.ts
|
||||
* 구조물도 한 장의 **상단 그림** — 서버가 낸 모양 목록(m, 위가 +y)을 SVG 로 그림 (PLAN 12장).
|
||||
*
|
||||
* ⚠ 읽기 전용 — 치수를 셈하지 않음(서버 `B08_Quantity_Engine_StructureFigure` 한 벌). CAD 편집은 뒤.
|
||||
* ⚠ 못 그리는 장은 빈칸 대신 **까닭**을 같은 자리에 띄움.
|
||||
* ⚠ 한 장 = 그림 6 : 표 4(실무 비율) — 그림 칸 높이를 화면 높이의 60% 로 둠.
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "./B08_Quantity_UI_StructureSheet_Formula";
|
||||
|
||||
export type FigureShape =
|
||||
| { kind: "path"; points: [number, number][]; role: "wall" | "guide"; dash: boolean }
|
||||
| {
|
||||
kind: "text";
|
||||
text: string;
|
||||
at: [number, number];
|
||||
align: "left" | "center" | "right";
|
||||
size: number;
|
||||
};
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
const ANCHOR = { left: "start", center: "middle", right: "end" } as const;
|
||||
|
||||
export const FIGURE_CSS = `
|
||||
.b08-sheet__figure { height: 60vh; min-height: 16rem; border: 1px solid var(--color-border);
|
||||
background: var(--color-surface); }
|
||||
.b08-sheet__figure svg { display: block; width: 100%; height: 100%; }
|
||||
.b08-sheet__figure--none { height: auto; min-height: 0; padding: 6px 10px; }
|
||||
.b08-fig__wall, .b08-fig__guide { fill: none; vector-effect: non-scaling-stroke; }
|
||||
.b08-fig__wall { stroke: var(--color-text); stroke-width: 2; }
|
||||
.b08-fig__guide { stroke: var(--color-text-secondary, #888); stroke-width: 1; }
|
||||
.b08-fig__guide.is-dash { stroke-dasharray: 6 4; }
|
||||
.b08-sheet__figure text { fill: var(--color-text); }
|
||||
`;
|
||||
|
||||
/** 그림 칸 — 모양이 없으면 까닭 한 줄. */
|
||||
export function figureSection(
|
||||
figure: FigureShape[] | null | undefined,
|
||||
reason: string | null | undefined,
|
||||
): HTMLElement {
|
||||
const box = el("div", "b08-sheet__figure");
|
||||
if (!figure?.length) {
|
||||
box.classList.add("b08-sheet__figure--none");
|
||||
box.append(el("p", "b08-quantity__message", (reason ?? "그림 없음").replace(/\*\*/g, "")));
|
||||
return box;
|
||||
}
|
||||
const svg = document.createElementNS(SVG_NS, "svg");
|
||||
const group = document.createElementNS(SVG_NS, "g");
|
||||
for (const shape of figure) {
|
||||
if (shape.kind === "text") {
|
||||
const text = document.createElementNS(SVG_NS, "text");
|
||||
text.setAttribute("x", String(shape.at[0]));
|
||||
text.setAttribute("y", String(-shape.at[1]));
|
||||
text.setAttribute("font-size", String(shape.size));
|
||||
text.setAttribute("text-anchor", ANCHOR[shape.align]);
|
||||
text.setAttribute("dominant-baseline", "middle");
|
||||
text.textContent = shape.text;
|
||||
group.append(text);
|
||||
continue;
|
||||
}
|
||||
const line = document.createElementNS(SVG_NS, "polyline");
|
||||
line.setAttribute("points", shape.points.map(([x, y]) => `${x},${-y}`).join(" "));
|
||||
line.setAttribute("class", `b08-fig__${shape.role}${shape.dash ? " is-dash" : ""}`);
|
||||
group.append(line);
|
||||
}
|
||||
svg.append(group);
|
||||
box.append(svg);
|
||||
// 보이는 틀은 **그려진 뒤 실제 글자 폭**으로 — 글자 폭을 서버가 짐작하지 않게.
|
||||
requestAnimationFrame(() => {
|
||||
if (!svg.isConnected) return;
|
||||
const b = group.getBBox();
|
||||
const pad = Math.max(b.width, b.height) * 0.04;
|
||||
svg.setAttribute(
|
||||
"viewBox",
|
||||
`${b.x - pad} ${b.y - pad} ${b.width + pad * 2} ${b.height + pad * 2}`,
|
||||
);
|
||||
});
|
||||
return box;
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
* ⛔ 탭 등록은 이 파일이 하지 않음 — `B08_Quantity_UI_Page.ts` 탭 배선은 브레인 몫(PLAN 0장 충돌 막이).
|
||||
* 부르는 법: `{ label: "구조물도", build: () => renderStructureSheets(projectId) }`
|
||||
* ⚠ 값을 셈하지 않음 — 서버(`/quantity/structure-sheets`)가 낸 단위당 값을 표기 자리수로만 접음.
|
||||
* ⚠ 상단 그림·하단 일위대가는 뒤 일감 — 지금은 가운데 원단위 수량표만.
|
||||
* ⚠ 한 장 = 상단 그림(`_StructureFigure`, 읽기 전용 SVG) + 원단위 수량표 + 하단 일위대가.
|
||||
* ⚠ 제원 저장은 칸 옆 [제원 저장] 한 번에 정본(`structures.json`)으로 감 — 옛 B07 폼 규약 그대로.
|
||||
* ⚠ 식 고치기(PLAN 3장 ⑤) — 칸에서 고치면 **이 화면이 같은 풀이기로 즉시** 다시 풂(왕복 없음).
|
||||
* [식 저장] 때는 식만 보내고 **값은 서버가 Node 로 다시 냄**(판정 Ⓐ). 저장은 양식 + 프로젝트 단위.
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from "./B08_Quantity_UI_StructureSheet_Spec";
|
||||
import { buildLibraryPanel, libraryLabel } from "./B08_Quantity_UI_StructureSheet_Library";
|
||||
import { unitPriceSection } from "./B08_Quantity_UI_StructureSheet_UnitPrice";
|
||||
import { FIGURE_CSS, figureSection, type FigureShape } from "./B08_Quantity_UI_StructureFigure";
|
||||
|
||||
export interface StructureSheetRow {
|
||||
no: number;
|
||||
@@ -75,6 +76,9 @@ export interface StructureSheet extends StandardSheetSpec {
|
||||
}[];
|
||||
notes: string[];
|
||||
unpriced_rows: string[];
|
||||
/** 상단 그림 모양 목록 — 못 그리는 장은 `null` + `figure_reason`(PLAN 12장). */
|
||||
figure?: FigureShape[] | null;
|
||||
figure_reason?: string | null;
|
||||
/** 양식으로 선 장이면 그 양식 — 없으면 지금 전개 줄(고정형 모양). */
|
||||
/** `imported_from` 없음 = 프로그램 기본을 읽는 중(가져오기 전). */
|
||||
library_item?: {
|
||||
@@ -82,9 +86,13 @@ export interface StructureSheet extends StandardSheetSpec {
|
||||
name: string;
|
||||
code?: string | null;
|
||||
imported_from?: string | null;
|
||||
/** 뽑아 온 원문 공사명(STmate 고정형) — 프로그램 기본 발행 때 가림. */
|
||||
origin_project?: string | null;
|
||||
};
|
||||
/** 화면이 조작 중 왕복 없이 다시 풀 장 한 벌(L=1, 고친 식 얹힘). */
|
||||
formula_sheet?: FormulaSheet;
|
||||
/** ㉱ 고정형 항목 규격(H)이 장 제원과 다름 — 금액은 서되 미확정 급(빨간 테두리·배지). */
|
||||
spec_mismatch?: { item_height_m: number; sheet_height_m: number } | null;
|
||||
/** 제원 칸의 대안 후보(실무 관측값 등) — 값은 안 바꾸고 보이기만. */
|
||||
var_candidates?: {
|
||||
name: string;
|
||||
@@ -124,6 +132,15 @@ const CSS = `
|
||||
.b08-sheet__formula select.is-changed { outline: 1px solid var(--color-accent, #6c8ebf); }
|
||||
.b08-sheet__formula .b08-sheet__digits { flex: 0 0 3.2rem; min-width: 3.2rem; }
|
||||
.b08-sheet__actions { display: flex; gap: 8px; align-items: center; }
|
||||
/* 수동 단가 — 빨간 테두리 + 「미확정 N건」 배지(PLAN 확정 ⑦). */
|
||||
.b08-unit__manual { outline: 2px solid var(--color-danger, #d9534f); outline-offset: -2px; }
|
||||
.b08-unit__badge { margin-left: 8px; padding: 0 6px; border-radius: 8px; color: #fff;
|
||||
background: var(--color-danger, #d9534f); font-size: 12px; white-space: nowrap; }
|
||||
.b08-unit-edit { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; }
|
||||
.b08-unit-edit td { white-space: nowrap; }
|
||||
.b08-unit-edit__picker { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
|
||||
.b08-unit-edit__results { display: flex; flex-direction: column; align-items: flex-start;
|
||||
gap: 2px; width: 100%; max-height: 14rem; overflow: auto; }
|
||||
@media (max-width: 900px) {
|
||||
.b08-sheet { flex-direction: column; }
|
||||
.b08-sheet__aside { flex-basis: auto; width: 100%; }
|
||||
@@ -135,7 +152,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);
|
||||
}
|
||||
|
||||
@@ -245,9 +262,23 @@ function sheetBody(
|
||||
: "양식 없음(지금 전개)"),
|
||||
),
|
||||
// 실무 시트 머리의 「m당」·「개소당」 — 종류마다 다름(통일하지 않음).
|
||||
el("span", "b08-grid__caption", sheet.unit_label),
|
||||
el(
|
||||
"span",
|
||||
"b08-grid__caption",
|
||||
// 10-A ⑯ 양식은 L=1 로 풂(`replace_with_templates` · m당 양식만) — 규칙을 머리에 드러냄.
|
||||
sheet.library_item && sheet.unit_label === "m당"
|
||||
? `m당 — 반올림은 m당 값에 걸고 수량 = m당 × 연장`
|
||||
: sheet.unit_label,
|
||||
),
|
||||
);
|
||||
main.append(head);
|
||||
// ㉱ 규격 다름 — 미확정과 같은 급(빨간 테두리 + 배지). 금액은 서고 막지 않음.
|
||||
if (sheet.spec_mismatch) {
|
||||
const { item_height_m: item, sheet_height_m: own } = sheet.spec_mismatch;
|
||||
head.append(el("span", "b08-unit__badge", `규격 다름 H${item} ↔ H${own}`));
|
||||
main.classList.add("b08-unit__manual");
|
||||
}
|
||||
// 한 장 = 상단 그림 + 하단 표(PLAN 3장 한 장의 짜임) — 못 그리는 장은 까닭.
|
||||
main.append(head, figureSection(sheet.figure, sheet.figure_reason));
|
||||
if (!sheet.rows.length) {
|
||||
main.append(
|
||||
el("p", "b08-quantity__message", "이 제원은 원단위 줄이 서지 않음 — 아래 사유 참고"),
|
||||
@@ -400,6 +431,8 @@ export function renderStructureSheets(projectId: string | null): HTMLElement {
|
||||
sheetKey: sheet.key,
|
||||
typeId: sheet.library_item.type_id,
|
||||
currentCode: sheet.library_item.code ?? null,
|
||||
originProject: sheet.library_item.origin_project,
|
||||
defaultName: sheet.title,
|
||||
isDirty: () => dirty,
|
||||
confirmTake: () => {
|
||||
const edited = editedRows(sheet);
|
||||
@@ -430,7 +463,9 @@ export function renderStructureSheets(projectId: string | null): HTMLElement {
|
||||
},
|
||||
)
|
||||
: null;
|
||||
const unitPrice = sheet.library_item ? unitPriceSection(projectId, sheet.key) : null;
|
||||
const unitPrice = sheet.library_item
|
||||
? unitPriceSection(projectId, sheet.key, sheet.rows)
|
||||
: null;
|
||||
pane.replaceChildren(sheetBody(sheet, editor, unitPrice), aside);
|
||||
};
|
||||
sheets.forEach((sheet, index) => {
|
||||
|
||||
@@ -171,7 +171,7 @@ export function formulaTable(
|
||||
saveButton.disabled = !dirty;
|
||||
discardButton.disabled = !dirty;
|
||||
status.textContent = dirty
|
||||
? "저장 안 한 식 있음 — 값은 이 화면 계산(저장하면 서버가 다시 셈 · 같은 양식의 장 모두에 걸림)"
|
||||
? "저장 안 한 식 있음 — 값은 이 화면 계산(저장하면 서버가 다시 셈 · 같은 양식의 장 모두에 걸림 · 고친 식은 이 양식·프로젝트에 묶여 제원(높이 등)을 바꿔도 따라감)"
|
||||
: "";
|
||||
onDirty(dirty);
|
||||
};
|
||||
|
||||
@@ -8,13 +8,24 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { buildStmatePanel } from "./B08_Quantity_UI_StructureSheet_Stmate";
|
||||
|
||||
const TIER_LABELS: Record<string, string> = { personal: "개인", company: "회사", program: "기본" };
|
||||
const TIER_LABELS: Record<string, string> = {
|
||||
personal: "개인",
|
||||
received: "받음",
|
||||
company: "회사",
|
||||
program: "기본",
|
||||
};
|
||||
/** 항목 종류 배지 — 양식형 = 제원을 바꾸면 수량이 다시 남 · 고정형 = 박힌 수량(명세 13장). */
|
||||
const KIND_LABELS: Record<string, string> = { form: "양식형", fixed: "고정형" };
|
||||
/** 이름표 — 「종류 + 제원 요약」을 미리 채워 두고 고치게 함(10-A ⑫ · 코드는 난수라 이름이 흔들려도 안전). */
|
||||
const NAME_TAG_HINT = "이름표(종류 + 제원 요약 · 고칠 수 있음 · 비우면 양식 이름):";
|
||||
|
||||
interface LibraryItem {
|
||||
tier: string;
|
||||
code: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
}
|
||||
|
||||
/** 장 머리의 양식 표시 — **어느 단에서 가져왔나** + **그 뒤 고쳤나**(지금 읽는 단이 아님). */
|
||||
@@ -48,6 +59,10 @@ export interface LibraryPanelOptions {
|
||||
/** 저장 안 한 식이 있으면 [내 라이브러리에 저장]을 막음 — 저장된 식만 개인 단으로 감. */
|
||||
isDirty: () => boolean;
|
||||
onImported: (notes: string[]) => Promise<void>;
|
||||
/** 이 장 양식이 뽑아 온 원문 공사명 — 프로그램 기본 발행 확인창에 「빼고 발행」을 알림. */
|
||||
originProject?: string | null;
|
||||
/** 저장·발행 이름표 제안 — 장 이름(종류 + 제원 요약). */
|
||||
defaultName: string;
|
||||
}
|
||||
|
||||
/** 가져오기 · [내 라이브러리에 저장] · [내 것 지우기] 칸. 개인 단 두 단추는 프로젝트를 안 바꿈. */
|
||||
@@ -76,13 +91,112 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
|
||||
take.className = "b08-spec__save";
|
||||
take.textContent = "가져오기";
|
||||
take.hidden = true;
|
||||
// 복제 — 기본·회사 항목을 개인 단에 베낌(PLAN 4장). 개인 단 것은 이미 내 것이라 안 눌림.
|
||||
const clone = document.createElement("button");
|
||||
clone.type = "button";
|
||||
clone.className = "b08-quantity__tab";
|
||||
clone.textContent = "복제해서 내 것으로";
|
||||
clone.hidden = true;
|
||||
/** 목록을 다시 받은 뒤 보일 한 줄 — 받는 동안 상태 줄이 지워져 복제 결과가 사라지지 않게. */
|
||||
let afterLoad = "";
|
||||
// 공유 — 내 개인 단 항목을 같은 회사 동료의 「받음」 단으로(브레인 판정 ①). 동료는 누를 때 받음.
|
||||
const send = document.createElement("button");
|
||||
send.type = "button";
|
||||
send.className = "b08-quantity__tab";
|
||||
send.textContent = "동료에게 보내기";
|
||||
send.hidden = true;
|
||||
const who = document.createElement("select");
|
||||
who.className = "b08-spec__input";
|
||||
who.hidden = true;
|
||||
const syncClone = (): void => {
|
||||
const [tier] = list.value.split("|");
|
||||
clone.disabled = tier === "personal";
|
||||
send.disabled = tier !== "personal";
|
||||
};
|
||||
send.addEventListener("click", () => {
|
||||
const [tier, code] = list.value.split("|");
|
||||
if (tier !== "personal" || !code) return;
|
||||
void (async () => {
|
||||
send.disabled = true;
|
||||
try {
|
||||
if (who.hidden) {
|
||||
const { colleagues } = await readJson<{ colleagues: { id: number; name: string }[] }>(
|
||||
await fetch(libraryUrl(projectId, "/colleagues"), { credentials: "include" }),
|
||||
);
|
||||
who.replaceChildren(...colleagues.map((c) => new Option(c.name, String(c.id))));
|
||||
who.hidden = colleagues.length === 0;
|
||||
status.textContent = colleagues.length
|
||||
? "받을 동료를 고르고 한 번 더 누를 것"
|
||||
: "보낼 동료가 없음";
|
||||
return;
|
||||
}
|
||||
const label = list.selectedOptions[0]?.textContent ?? "";
|
||||
const name = who.selectedOptions[0]?.textContent ?? "";
|
||||
if (
|
||||
!window.confirm(
|
||||
`「${label}」을 ${name}에게 보냄 — 받는 쪽 목록에 「받음」으로 뜸 · 그 사람 것은 안 덮음`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await readJson(
|
||||
await fetch(libraryUrl(projectId, "/share"), {
|
||||
method: "PUT",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type_id: typeId, code, to_user_id: Number(who.value) }),
|
||||
}),
|
||||
);
|
||||
who.hidden = true;
|
||||
status.textContent = `${name}에게 보냄`;
|
||||
} catch (error) {
|
||||
status.textContent = error instanceof Error ? error.message : "보내지 못함";
|
||||
} finally {
|
||||
syncClone();
|
||||
}
|
||||
})();
|
||||
});
|
||||
list.addEventListener("change", syncClone);
|
||||
clone.addEventListener("click", () => {
|
||||
const [tier, code] = list.value.split("|");
|
||||
const label = list.selectedOptions[0]?.textContent ?? "";
|
||||
if (!tier || !code || tier === "personal") return;
|
||||
if (
|
||||
!window.confirm(
|
||||
`「${label}」을 내 라이브러리로 베낌 — 같은 종류 내 것이 있으면 덮어씀 · 프로젝트 값은 안 바뀜`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
clone.disabled = true;
|
||||
try {
|
||||
await readJson(
|
||||
await fetch(libraryUrl(projectId, "/clone"), {
|
||||
method: "PUT",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type_id: typeId, tier, code }),
|
||||
}),
|
||||
);
|
||||
afterLoad = "내 라이브러리에 베낌 — 가져와 고친 뒤 [내 라이브러리에 저장]";
|
||||
load.click();
|
||||
} catch (error) {
|
||||
status.textContent = error instanceof Error ? error.message : "복제 못함";
|
||||
syncClone();
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
load.addEventListener("click", () => {
|
||||
void (async () => {
|
||||
load.disabled = true;
|
||||
status.textContent = "목록 받는 중…";
|
||||
try {
|
||||
const { items } = await readJson<{ items: LibraryItem[] }>(
|
||||
const { items, can_publish: canPublish } = await readJson<{
|
||||
items: LibraryItem[];
|
||||
can_publish?: { company: boolean; program: boolean };
|
||||
}>(
|
||||
await fetch(libraryUrl(projectId, `?type_id=${encodeURIComponent(typeId)}`), {
|
||||
credentials: "include",
|
||||
}),
|
||||
@@ -92,12 +206,18 @@ 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;
|
||||
}),
|
||||
);
|
||||
list.hidden = take.hidden = items.length === 0;
|
||||
status.textContent = items.length ? "" : "가져올 항목이 없음";
|
||||
list.hidden = take.hidden = clone.hidden = send.hidden = items.length === 0;
|
||||
syncClone();
|
||||
// 발행 단추 — 서버가 준 권한대로만 보임(회사 = 마스터 · 기본 = 시스템 관리자).
|
||||
toCompany.hidden = !canPublish?.company;
|
||||
toProgram.hidden = !canPublish?.program;
|
||||
status.textContent = items.length ? afterLoad : "가져올 항목이 없음";
|
||||
afterLoad = "";
|
||||
} catch (error) {
|
||||
status.textContent = error instanceof Error ? error.message : "목록을 받지 못함";
|
||||
} finally {
|
||||
@@ -156,17 +276,17 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
|
||||
};
|
||||
const save = personal("내 라이브러리에 저장", async () => {
|
||||
if (isDirty()) return "저장 안 한 식이 있음 — [식 저장] 먼저";
|
||||
if (
|
||||
!window.confirm("이 장의 양식과 고친 식을 내 라이브러리에 저장 — 같은 종류가 있으면 덮어씀")
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
const tag = window.prompt(
|
||||
`이 장의 양식과 고친 식을 내 라이브러리에 저장 — 같은 종류가 있으면 덮어씀\n${NAME_TAG_HINT}`,
|
||||
options.defaultName,
|
||||
);
|
||||
if (tag === null) return "";
|
||||
const result = await readJson<{ edited: number }>(
|
||||
await fetch(libraryUrl(projectId, "/personal"), {
|
||||
method: "PUT",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sheet_key: sheetKey }),
|
||||
body: JSON.stringify({ sheet_key: sheetKey, name: tag }),
|
||||
}),
|
||||
);
|
||||
return `내 라이브러리에 저장함${result.edited ? ` · 고친 식 ${result.edited}줄 포함` : ""}`;
|
||||
@@ -182,10 +302,49 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
|
||||
);
|
||||
return result.deleted ? "내 라이브러리에서 지움" : "지울 내 양식이 없음";
|
||||
});
|
||||
// 발행 — [내 라이브러리에 저장]과 같은 모양으로 회사·프로그램 기본 단에(2026-09-14 브레인 승인).
|
||||
const publish = (label: string, tier: "company" | "program"): HTMLButtonElement => {
|
||||
const button = personal(label, async () => {
|
||||
if (isDirty()) return "저장 안 한 식이 있음 — [식 저장] 먼저";
|
||||
const whom = tier === "program" ? "모든 회사가 쓰는 프로그램 기본" : "우리 회사 라이브러리";
|
||||
const masked =
|
||||
tier === "program" && options.originProject
|
||||
? `\n이 항목은 「${options.originProject}」에서 뽑은 것 — 공사명은 빼고 발행됩니다`
|
||||
: "";
|
||||
const tag = window.prompt(
|
||||
`이 장의 양식과 고친 식을 ${whom}에 발행 — 같은 종류가 있으면 덮어씀${masked}\n${NAME_TAG_HINT}`,
|
||||
options.defaultName,
|
||||
);
|
||||
if (tag === null) return "";
|
||||
await readJson(
|
||||
await fetch(libraryUrl(projectId, "/publish"), {
|
||||
method: "PUT",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sheet_key: sheetKey, tier, name: tag }),
|
||||
}),
|
||||
);
|
||||
return `${whom}에 발행함`;
|
||||
});
|
||||
button.hidden = true;
|
||||
return button;
|
||||
};
|
||||
const toCompany = publish("회사 라이브러리에 발행", "company");
|
||||
const toProgram = publish("프로그램 기본으로 발행", "program");
|
||||
const mine = document.createElement("div");
|
||||
mine.className = "b08-sheet__actions";
|
||||
mine.append(save, remove);
|
||||
mine.append(save, remove, toCompany, toProgram);
|
||||
|
||||
panel.append(title, scope, load, list, take, mine, status);
|
||||
panel.append(title, scope, load, list, take, clone, send, who, mine, status);
|
||||
// 고정형 항목을 만드는 둘째 길 — STmate 출력 엑셀에서 호표 하나를 뽑아 개인 단에(PLAN 4장).
|
||||
panel.append(
|
||||
buildStmatePanel({
|
||||
projectId,
|
||||
typeId,
|
||||
onSaved: () => {
|
||||
if (!list.hidden) load.click();
|
||||
},
|
||||
}),
|
||||
);
|
||||
return panel;
|
||||
}
|
||||
|
||||
@@ -196,7 +196,11 @@ export function buildStandardSpecPanel(
|
||||
slope,
|
||||
judgedSlope ? `비우면 자동 — 지금 판정값 1:${judgedSlope}` : "비우면 자동으로 판정합니다.",
|
||||
),
|
||||
field("야면석 계수", coeff, "비우면 품셈 열을 씁니다."),
|
||||
field(
|
||||
"야면석 계수",
|
||||
coeff,
|
||||
"비우면 품셈 열을 씁니다. 계수 열을 바꿔도 돌 종류는 그대로(다른 칸)입니다.",
|
||||
),
|
||||
field("채움 강도 (MPa)", mpa, "비우면 210. 180 은 국가기준 하한입니다."),
|
||||
field("버림 콘크리트", blinding, "비우면 넣습니다(두께 100㎜) — 빼려면 「안 넣음」."),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/* =============================================================================
|
||||
* B08_Quantity_UI_StructureSheet_Stmate.ts
|
||||
* 라이브러리 칸 — [STmate 엑셀에서 뽑기] (PLAN 4장 · 2026-09-14 브레인 판정).
|
||||
*
|
||||
* 두 걸음: [읽기] → 호표 수·구성 줄 수·못 읽은 사유를 보임(아무것도 안 씀) → 호표를 고르고
|
||||
* [내 라이브러리에 넣기] → 수를 다시 묻고 **파일을 다시 보냄**(서버가 다시 읽어 씀 — 화면은 줄을 안 보냄).
|
||||
* ⚠ 종류는 지금 보고 있는 장의 종류 — 이름으로 자동으로 안 붙임(판정 Ⓒ).
|
||||
* ⚠ 모양은 옆 제원 칸(`b08-spec*`) 클래스를 그대로 씀.
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
|
||||
interface ReadHopyo {
|
||||
no: number;
|
||||
name: string;
|
||||
spec: string;
|
||||
unit: string;
|
||||
rows: number;
|
||||
percent_rows: number;
|
||||
contract_rows: number;
|
||||
}
|
||||
|
||||
interface ReadResult {
|
||||
project: string;
|
||||
hopyo: ReadHopyo[];
|
||||
counts: { hopyo: number; rows: number };
|
||||
problems: string[];
|
||||
}
|
||||
|
||||
async function postForm<T>(url: string, form: FormData): Promise<T> {
|
||||
const response = await fetch(url, { method: "POST", credentials: "include", body: form });
|
||||
const payload = (await response.json().catch(() => ({}))) as T & { message?: string };
|
||||
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function buildStmatePanel(options: {
|
||||
projectId: string;
|
||||
typeId: string;
|
||||
/** 넣은 뒤 — 목록을 다시 받게 함. */
|
||||
onSaved: () => void;
|
||||
}): HTMLElement {
|
||||
const { projectId, typeId, onSaved } = options;
|
||||
const base = `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/structure-sheets/library`;
|
||||
const box = document.createElement("div");
|
||||
box.className = "b08-sheet__actions";
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = ".xlsx";
|
||||
input.className = "b08-spec__input";
|
||||
input.title = "STmate 가 내보낸 내역 엑셀(일위대가표 시트)";
|
||||
const read = document.createElement("button");
|
||||
read.type = "button";
|
||||
read.className = "b08-quantity__tab";
|
||||
read.textContent = "STmate 엑셀 읽기";
|
||||
const list = document.createElement("select");
|
||||
list.className = "b08-spec__input";
|
||||
list.hidden = true;
|
||||
const put = document.createElement("button");
|
||||
put.type = "button";
|
||||
put.className = "b08-spec__save";
|
||||
put.textContent = "내 라이브러리에 넣기";
|
||||
put.hidden = true;
|
||||
const status = document.createElement("p");
|
||||
status.className = "b08-spec__scope";
|
||||
|
||||
let result: ReadResult | null = null;
|
||||
input.addEventListener("change", () => {
|
||||
result = null;
|
||||
list.hidden = put.hidden = true;
|
||||
status.textContent = "";
|
||||
});
|
||||
|
||||
read.addEventListener("click", () => {
|
||||
const file = input.files?.[0];
|
||||
if (!file) {
|
||||
status.textContent = "엑셀 파일을 먼저 고르세요";
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
read.disabled = true;
|
||||
status.textContent = "읽는 중…";
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
result = await postForm<ReadResult>(`${base}/stmate/read`, form);
|
||||
list.replaceChildren(
|
||||
...result.hopyo.map((h) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = String(h.no);
|
||||
const extra = h.percent_rows ? ` · 가산 행 ${h.percent_rows}` : "";
|
||||
option.textContent = `제${h.no}호표 ${h.name} ${h.spec} / ${h.unit} · 구성 ${h.rows}줄${extra}`;
|
||||
return option;
|
||||
}),
|
||||
);
|
||||
list.hidden = put.hidden = result.hopyo.length === 0;
|
||||
const missed = result.problems.length
|
||||
? ` · 못 읽은 것 ${result.problems.length}건 — ${result.problems.slice(0, 3).join(" / ")}`
|
||||
: "";
|
||||
status.textContent = `「${result.project}」 호표 ${result.counts.hopyo}개 · 구성 줄 ${result.counts.rows}줄 읽음${missed}`;
|
||||
} catch (error) {
|
||||
status.textContent = error instanceof Error ? error.message : "읽지 못함";
|
||||
} finally {
|
||||
read.disabled = false;
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
put.addEventListener("click", () => {
|
||||
const file = input.files?.[0];
|
||||
const picked = result?.hopyo.find((h) => String(h.no) === list.value);
|
||||
if (!file || !result || !picked) return;
|
||||
const question =
|
||||
`읽은 것: 호표 ${result.counts.hopyo}개 · 구성 줄 ${result.counts.rows}줄\n` +
|
||||
`넣을 것: 「${picked.name} ${picked.spec}」 구성 ${picked.rows}줄 → 이 장 종류로 내 라이브러리\n` +
|
||||
"· 같은 종류 내 것이 있으면 덮어씀\n· 수량은 원문 시점값(현행 품셈 대조 전) · 단가는 안 가져옴";
|
||||
if (!window.confirm(question)) return;
|
||||
void (async () => {
|
||||
put.disabled = true;
|
||||
status.textContent = "넣는 중…";
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
form.append("hopyo_no", String(picked.no));
|
||||
form.append("type_id", typeId);
|
||||
const saved = await postForm<{ code: string; name: string; note: string }>(
|
||||
`${base}/stmate/save`,
|
||||
form,
|
||||
);
|
||||
status.textContent = `내 라이브러리에 넣음 — 「${saved.name}」 [고정형] · ${saved.note}`;
|
||||
onSaved();
|
||||
} catch (error) {
|
||||
status.textContent = error instanceof Error ? error.message : "넣지 못함";
|
||||
} finally {
|
||||
put.disabled = false;
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
box.append(read, put);
|
||||
wrap.append(input, box, list, status);
|
||||
return wrap;
|
||||
}
|
||||
@@ -1,14 +1,19 @@
|
||||
/* =============================================================================
|
||||
* B08_Quantity_UI_StructureSheet_UnitPrice.ts
|
||||
* 구조물도 장 아래 **일위대가 표**(미리보기) — PLAN 3장 하단 ①②.
|
||||
* 구조물도 장 아래 **일위대가 표**(미리보기) — PLAN 3장 하단 ①②③.
|
||||
*
|
||||
* ⚠ 값을 셈하지 않음 — 서버(`…/structure-sheets/unit-price`)가 B09 단가표로 낸 금액을 적기만.
|
||||
* ⚠ 장 조회와 따로 받음 — 단가표 첫 조립이 십여 초라 표가 늦게 차도 위 수량표는 먼저 보임.
|
||||
* ⚠ 막힌 줄은 0 이 아니라 까닭을 적고, 하나라도 있으면 합계 앞에 「미완」.
|
||||
* ⚠ 수동 단가 줄은 금액 칸 빨간 테두리 + 머리 「미확정 N건」 배지(PLAN 확정 ⑦).
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { el, num } from "./B08_Quantity_UI_StructureSheet_Formula";
|
||||
import {
|
||||
unitPriceEditor,
|
||||
type UnitPriceEditorData,
|
||||
} from "./B08_Quantity_UI_StructureSheet_UnitPriceEdit";
|
||||
|
||||
interface UnitPriceRow {
|
||||
seq: number;
|
||||
@@ -23,6 +28,9 @@ interface UnitPriceRow {
|
||||
labor?: number;
|
||||
expense?: number;
|
||||
total?: number;
|
||||
manual?: boolean;
|
||||
manual_source?: string;
|
||||
manual_entered_at?: string;
|
||||
}
|
||||
|
||||
interface UnitPriceTable {
|
||||
@@ -36,6 +44,7 @@ interface UnitPriceTable {
|
||||
total: number;
|
||||
blocked: number;
|
||||
complete: boolean;
|
||||
unconfirmed: number;
|
||||
}
|
||||
|
||||
/** 금액 칸 — 0.1원 자리까지(금액란 규칙). 못 푼 줄은 빈칸. */
|
||||
@@ -43,13 +52,17 @@ function won(value: number | undefined): string {
|
||||
return value === undefined ? "" : num(value, 1);
|
||||
}
|
||||
|
||||
/** 장 아래 일위대가 칸 — 받는 동안 안내를 두고, 오면 표로 갈음. */
|
||||
export function unitPriceSection(projectId: string, sheetKey: string): HTMLElement {
|
||||
/** 장 아래 일위대가 칸 — 받는 동안 안내를 두고, 오면 표로 갈음. 저장 뒤엔 다시 받음. */
|
||||
export function unitPriceSection(
|
||||
projectId: string,
|
||||
sheetKey: string,
|
||||
sheetRows: { no: number; name: string; unit: string }[],
|
||||
): HTMLElement {
|
||||
const wrap = el("div", "b08-grid");
|
||||
wrap.append(
|
||||
el("p", "b08-grid__caption", "일위대가(미리보기) 불러오는 중… 단가표 첫 조립은 십여 초"),
|
||||
);
|
||||
void (async () => {
|
||||
const load = async (): Promise<void> => {
|
||||
wrap.replaceChildren(
|
||||
el("p", "b08-grid__caption", "일위대가(미리보기) 불러오는 중… 단가표 첫 조립은 십여 초"),
|
||||
);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/structure-sheets/unit-price?sheet_key=${encodeURIComponent(sheetKey)}`,
|
||||
@@ -57,15 +70,38 @@ export function unitPriceSection(projectId: string, sheetKey: string): HTMLEleme
|
||||
);
|
||||
const payload = (await response.json().catch(() => ({}))) as {
|
||||
unit_price?: UnitPriceTable | null;
|
||||
editor?: UnitPriceEditorData;
|
||||
message?: string;
|
||||
};
|
||||
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
|
||||
const table = payload.unit_price;
|
||||
if (!table) {
|
||||
const editor = payload.editor;
|
||||
if (!table || !editor) {
|
||||
wrap.replaceChildren(el("p", "b08-grid__caption", "이 양식에는 일위대가 줄이 아직 없음"));
|
||||
return;
|
||||
}
|
||||
wrap.replaceChildren(...render(table));
|
||||
const open = el("button", "", editor.edited ? "줄 고치기 (고친 조합)" : "줄 고치기");
|
||||
open.type = "button";
|
||||
open.style.alignSelf = "flex-start";
|
||||
const view = table.rows.length
|
||||
? render(table)
|
||||
: [el("p", "b08-grid__caption", "일위대가 줄이 아직 없음 — [줄 고치기]로 더함")];
|
||||
open.addEventListener("click", () => {
|
||||
open.disabled = true;
|
||||
const panel = unitPriceEditor({
|
||||
projectId,
|
||||
sheetKey,
|
||||
sheetRows,
|
||||
data: editor,
|
||||
onSaved: () => void load(),
|
||||
onClose: () => {
|
||||
panel.remove();
|
||||
open.disabled = false;
|
||||
},
|
||||
});
|
||||
wrap.append(panel);
|
||||
});
|
||||
wrap.replaceChildren(...view, open);
|
||||
} catch (error) {
|
||||
wrap.replaceChildren(
|
||||
el(
|
||||
@@ -75,7 +111,8 @@ export function unitPriceSection(projectId: string, sheetKey: string): HTMLEleme
|
||||
),
|
||||
);
|
||||
}
|
||||
})();
|
||||
};
|
||||
void load();
|
||||
return wrap;
|
||||
}
|
||||
|
||||
@@ -86,8 +123,11 @@ function render(table: UnitPriceTable): HTMLElement[] {
|
||||
const head = el(
|
||||
"p",
|
||||
"b08-sheet__head",
|
||||
`일위대가 ${table.code} · ${table.unit}당 ${total} (미리보기 — 내역 금액은 원가계산이 셈)`,
|
||||
`일위대가 ${table.code} · ${table.unit}당 ${total} (미리보기 — 내역 금액은 원가계산이 셈 · 하위 일위대가는 5단까지 풀고 더 깊거나 돌면 막힘)`,
|
||||
);
|
||||
if (table.unconfirmed) {
|
||||
head.append(el("span", "b08-unit__badge", `미확정 ${table.unconfirmed}건`));
|
||||
}
|
||||
const scroller = el("div", "b08-grid__scroll");
|
||||
const grid = el("table", "b08-grid__table b08-grid__table--summary");
|
||||
const headRow = document.createElement("tr");
|
||||
@@ -109,19 +149,24 @@ function render(table: UnitPriceTable): HTMLElement[] {
|
||||
const tbody = document.createElement("tbody");
|
||||
for (const row of table.rows) {
|
||||
const tr = document.createElement("tr");
|
||||
const note = row.skipped ? `안 섬 — ${row.reason}` : row.reason ? `⚠ ${row.reason}` : "";
|
||||
const manual = row.manual
|
||||
? `수동 단가 — ${row.manual_source || "출처 없음"} · ${row.manual_entered_at ?? ""}`
|
||||
: "";
|
||||
const note = row.skipped ? `안 섬 — ${row.reason}` : row.reason ? `⚠ ${row.reason}` : manual;
|
||||
// 단가표 이름에 갈래가 이미 들었거나 규격 칸이 코드 자체면 겹쳐 적지 않음.
|
||||
const spec =
|
||||
row.spec && !row.name.includes(row.spec) && !row.ref_code.includes(row.spec) ? row.spec : "";
|
||||
const money = [row.material, row.labor, row.expense, row.total].map((value) => {
|
||||
const cell = el("td", row.manual ? "b08-unit__manual" : "", won(value));
|
||||
if (row.manual) cell.title = manual;
|
||||
return cell;
|
||||
});
|
||||
tr.append(
|
||||
el("td", "", spec ? `${row.name} (${spec})` : row.name),
|
||||
el("td", "", row.ref_code),
|
||||
el("td", "", row.quantity === null ? "" : num(row.quantity, 3)),
|
||||
el("td", "", row.unit),
|
||||
el("td", "", won(row.material)),
|
||||
el("td", "", won(row.labor)),
|
||||
el("td", "", won(row.expense)),
|
||||
el("td", "", won(row.total)),
|
||||
...money,
|
||||
el("td", "", note),
|
||||
);
|
||||
tbody.append(tr);
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
/* =============================================================================
|
||||
* B08_Quantity_UI_StructureSheet_UnitPriceEdit.ts
|
||||
* 구조물도 일위대가 **줄 고치기** — 줄 더하기·빼기 · 고르개(품셈·자원 찾기) · 수동 단가. PLAN 3장 ③.
|
||||
*
|
||||
* ⚠ 저장 자리는 둘(브레인 판정) — 줄 조합은 양식+프로젝트, 수동 단가는 프로젝트만. 한 번의 [저장]이
|
||||
* 둘을 함께 보내고 서버가 갈라 적음. [내 라이브러리에 저장]은 줄 조합만 실음.
|
||||
* ⚠ 금액은 여기서 셈하지 않음 — 저장 뒤 표를 다시 받아 서버 계산(B09 단가표)으로 그림.
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { el } from "./B08_Quantity_UI_StructureSheet_Formula";
|
||||
|
||||
export interface UnitPriceSpecRow {
|
||||
seq: number;
|
||||
name?: string;
|
||||
spec?: string;
|
||||
unit?: string;
|
||||
from_row?: number;
|
||||
quantity?: number;
|
||||
ref_code?: string;
|
||||
work_item_code?: string;
|
||||
variant_from?: string;
|
||||
sub_vars?: Record<string, number | string>;
|
||||
}
|
||||
|
||||
export interface ManualPrice {
|
||||
material: number;
|
||||
labor: number;
|
||||
expense: number;
|
||||
source: string;
|
||||
entered_at?: string;
|
||||
}
|
||||
|
||||
export interface UnitPriceEditorData {
|
||||
rows: UnitPriceSpecRow[];
|
||||
default_rows: UnitPriceSpecRow[];
|
||||
edited: boolean;
|
||||
manual_prices: Record<string, ManualPrice>;
|
||||
}
|
||||
|
||||
interface SearchItem {
|
||||
code: string;
|
||||
name: string;
|
||||
spec: string;
|
||||
unit: string;
|
||||
price: number | null;
|
||||
}
|
||||
|
||||
interface EditorOptions {
|
||||
projectId: string;
|
||||
sheetKey: string;
|
||||
sheetRows: { no: number; name: string; unit: string }[];
|
||||
data: UnitPriceEditorData;
|
||||
onSaved: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const MONEY: [keyof Omit<ManualPrice, "source" | "entered_at">, string][] = [
|
||||
["material", "재료비"],
|
||||
["labor", "노무비"],
|
||||
["expense", "경비"],
|
||||
];
|
||||
|
||||
function sheetUrl(projectId: string, tail: string): string {
|
||||
return `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/structure-sheets${tail}`;
|
||||
}
|
||||
|
||||
function input(value: string | number | undefined, width: string, type = "text"): HTMLInputElement {
|
||||
const node = document.createElement("input");
|
||||
node.type = type;
|
||||
node.value = value === undefined ? "" : String(value);
|
||||
node.style.width = width;
|
||||
return node;
|
||||
}
|
||||
|
||||
function codeText(row: UnitPriceSpecRow): string {
|
||||
if (row.ref_code) return row.ref_code;
|
||||
if (row.work_item_code) {
|
||||
return row.variant_from
|
||||
? `${row.work_item_code} (갈래: ${row.variant_from})`
|
||||
: row.work_item_code;
|
||||
}
|
||||
return "코드 없음";
|
||||
}
|
||||
|
||||
/** 줄 고치기 칸 — 로컬에서만 고치다 [저장]에 서버로. */
|
||||
export function unitPriceEditor(options: EditorOptions): HTMLElement {
|
||||
const { projectId, sheetKey, sheetRows, data, onSaved, onClose } = options;
|
||||
let rows: UnitPriceSpecRow[] = structuredClone(data.rows);
|
||||
const manual: Record<string, ManualPrice> = structuredClone(data.manual_prices);
|
||||
let picking: number | null = null;
|
||||
|
||||
const wrap = el("div", "b08-unit-edit");
|
||||
const tbody = document.createElement("tbody");
|
||||
const picker = el("div", "b08-unit-edit__picker");
|
||||
const status = el("span", "b08-grid__caption");
|
||||
|
||||
const render = (): void => {
|
||||
tbody.replaceChildren(...rows.map((row, index) => rowOf(row, index)));
|
||||
picker.hidden = picking === null;
|
||||
};
|
||||
|
||||
const rowOf = (row: UnitPriceSpecRow, index: number): HTMLTableRowElement => {
|
||||
const tr = document.createElement("tr");
|
||||
const name = input(row.name, "9rem");
|
||||
name.addEventListener("input", () => (row.name = name.value || undefined));
|
||||
|
||||
// 수량 — 원단위 줄을 따르거나 박힌 값.
|
||||
const source = document.createElement("select");
|
||||
source.append(new Option("박힌 값", ""));
|
||||
for (const line of sheetRows) {
|
||||
source.append(new Option(`원단위 ${line.no}. ${line.name} (${line.unit})`, String(line.no)));
|
||||
}
|
||||
source.value = row.from_row ? String(row.from_row) : "";
|
||||
const fixed = input(row.quantity, "5rem", "number");
|
||||
fixed.step = "any";
|
||||
fixed.disabled = Boolean(row.from_row);
|
||||
source.addEventListener("change", () => {
|
||||
row.from_row = source.value ? Number(source.value) : undefined;
|
||||
if (row.from_row) delete row.quantity;
|
||||
fixed.disabled = Boolean(row.from_row);
|
||||
});
|
||||
fixed.addEventListener("input", () => {
|
||||
row.quantity = fixed.value === "" ? undefined : Number(fixed.value);
|
||||
});
|
||||
const quantity = el("td", "");
|
||||
quantity.append(source, fixed);
|
||||
|
||||
const unit = input(row.unit, "3rem");
|
||||
unit.addEventListener("input", () => (row.unit = unit.value || undefined));
|
||||
|
||||
const code = el("td", "", codeText(row));
|
||||
const find = el("button", "", "찾기");
|
||||
find.type = "button";
|
||||
find.title = "품셈·자원을 찾아 이 줄의 코드로 넣음";
|
||||
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);
|
||||
|
||||
// 수동 단가 — 넣으면 빨간 테두리 · 표 머리에 「미확정」으로 셈. 프로젝트에만 저장됨.
|
||||
const price = el("td", "");
|
||||
const key = String(row.seq);
|
||||
const on = document.createElement("input");
|
||||
on.type = "checkbox";
|
||||
on.checked = key in manual;
|
||||
on.title = "수동 단가 — 이 프로젝트에만 저장(라이브러리로 안 감)";
|
||||
price.append(on, " 수동");
|
||||
const fields = MONEY.map(([field, label]) => {
|
||||
const box = input(manual[key]?.[field], "5.5rem", "number");
|
||||
box.min = "0";
|
||||
box.step = "any";
|
||||
box.placeholder = label;
|
||||
box.title = `${label} 단가(단위당)`;
|
||||
box.addEventListener("input", () => {
|
||||
if (manual[key]) manual[key][field] = Number(box.value || 0);
|
||||
});
|
||||
return box;
|
||||
});
|
||||
const origin = input(manual[key]?.source, "7rem");
|
||||
origin.placeholder = "출처";
|
||||
origin.addEventListener("input", () => {
|
||||
if (manual[key]) manual[key].source = origin.value;
|
||||
});
|
||||
const sync = (): void => {
|
||||
for (const box of [...fields, origin]) {
|
||||
box.disabled = !on.checked;
|
||||
box.classList.toggle("b08-unit__manual", on.checked);
|
||||
}
|
||||
};
|
||||
on.addEventListener("change", () => {
|
||||
if (on.checked) {
|
||||
manual[key] = { material: 0, labor: 0, expense: 0, source: "" };
|
||||
fields.forEach((box, i) => (manual[key][MONEY[i][0]] = Number(box.value || 0)));
|
||||
manual[key].source = origin.value;
|
||||
} else {
|
||||
delete manual[key];
|
||||
}
|
||||
sync();
|
||||
});
|
||||
sync();
|
||||
price.append(...fields, origin);
|
||||
if (manual[key]?.entered_at) price.append(` ${manual[key].entered_at}`);
|
||||
|
||||
const remove = el("button", "", "빼기");
|
||||
remove.type = "button";
|
||||
remove.addEventListener("click", () => {
|
||||
rows = rows.filter((_, i) => i !== index);
|
||||
delete manual[key];
|
||||
picking = null;
|
||||
render();
|
||||
});
|
||||
const actions = el("td", "");
|
||||
actions.append(remove);
|
||||
|
||||
const nameCell = el("td", "");
|
||||
nameCell.append(name);
|
||||
const unitCell = el("td", "");
|
||||
unitCell.append(unit);
|
||||
tr.append(el("td", "", String(row.seq)), nameCell, quantity, unitCell, code, price, actions);
|
||||
return tr;
|
||||
};
|
||||
|
||||
// 고르개 — 이 프로젝트 단가표에서 낱말로 찾음.
|
||||
const kind = document.createElement("select");
|
||||
kind.append(new Option("품셈(일위대가)", "work"), new Option("자원(자재·노임·중기)", "resource"));
|
||||
const query = input("", "12rem");
|
||||
query.placeholder = "이름·코드·규격 낱말";
|
||||
const go = el("button", "", "찾기");
|
||||
go.type = "button";
|
||||
const shut = el("button", "", "닫기");
|
||||
shut.type = "button";
|
||||
const results = el("div", "b08-unit-edit__results");
|
||||
const search = async (): Promise<void> => {
|
||||
if (!query.value.trim()) return;
|
||||
results.replaceChildren(el("span", "b08-grid__caption", "찾는 중…"));
|
||||
try {
|
||||
const response = await fetch(
|
||||
sheetUrl(
|
||||
projectId,
|
||||
`/price-search?q=${encodeURIComponent(query.value)}&kind=${kind.value}`,
|
||||
),
|
||||
{ credentials: "include" },
|
||||
);
|
||||
const payload = (await response.json()) as { items?: SearchItem[]; message?: string };
|
||||
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
|
||||
const items = payload.items ?? [];
|
||||
results.replaceChildren(
|
||||
...(items.length
|
||||
? items.map(choice)
|
||||
: [
|
||||
// 후보 0 — 조용히 막히지 않게 다음 길을 적음(갈래 바꿔 찾기 · 수동 단가는 미확정으로 셈).
|
||||
el(
|
||||
"span",
|
||||
"b08-grid__caption",
|
||||
"단가표에 후보 없음 — 낱말·갈래(품셈/자원)를 바꿔 찾거나, 이 줄에 수동 단가를 넣을 것(미확정으로 셈)",
|
||||
),
|
||||
]),
|
||||
);
|
||||
} catch (error) {
|
||||
results.replaceChildren(
|
||||
el("span", "b08-grid__caption", `못 찾음 — ${error instanceof Error ? error.message : ""}`),
|
||||
);
|
||||
}
|
||||
};
|
||||
const choice = (item: SearchItem): HTMLElement => {
|
||||
const price = item.price === null ? "단가 안 섬" : `${item.price.toLocaleString("ko-KR")}원`;
|
||||
const button = el(
|
||||
"button",
|
||||
"",
|
||||
`${item.code} · ${item.name}${item.spec ? ` ${item.spec}` : ""} · ${item.unit} · ${price}`,
|
||||
);
|
||||
button.type = "button";
|
||||
button.addEventListener("click", () => {
|
||||
const row = picking === null ? undefined : rows[picking];
|
||||
if (!row) return;
|
||||
row.ref_code = item.code;
|
||||
row.name = item.name;
|
||||
row.spec = item.spec || undefined;
|
||||
row.unit = item.unit || undefined;
|
||||
delete row.work_item_code;
|
||||
delete row.variant_from;
|
||||
delete row.sub_vars;
|
||||
picking = null;
|
||||
render();
|
||||
});
|
||||
return button;
|
||||
};
|
||||
go.addEventListener("click", () => void search());
|
||||
query.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") void search();
|
||||
});
|
||||
shut.addEventListener("click", () => {
|
||||
picking = null;
|
||||
render();
|
||||
});
|
||||
picker.append(kind, query, go, shut, results);
|
||||
|
||||
const grid = el("table", "b08-grid__table");
|
||||
const head = document.createElement("tr");
|
||||
for (const label of ["차례", "이름", "수량", "단위", "코드", "단가 수동(단위당)", ""]) {
|
||||
head.append(el("th", "", label));
|
||||
}
|
||||
const thead = document.createElement("thead");
|
||||
thead.append(head);
|
||||
grid.append(thead, tbody);
|
||||
const scroller = el("div", "b08-grid__scroll");
|
||||
scroller.append(grid);
|
||||
|
||||
const add = el("button", "", "줄 더하기");
|
||||
add.type = "button";
|
||||
add.addEventListener("click", () => {
|
||||
// 양식 줄 차례와도 안 겹치게 — 뺀 양식 줄의 차례를 새 줄이 물려받지 않음.
|
||||
const seq = Math.max(0, ...rows.map((r) => r.seq), ...data.default_rows.map((r) => r.seq)) + 1;
|
||||
rows.push({ seq, name: "", quantity: 1 });
|
||||
render();
|
||||
});
|
||||
const reset = el("button", "", "양식대로");
|
||||
reset.type = "button";
|
||||
reset.title = "줄 조합을 양식 원래대로 — [저장]해야 반영";
|
||||
reset.addEventListener("click", () => {
|
||||
rows = structuredClone(data.default_rows);
|
||||
picking = null;
|
||||
render();
|
||||
});
|
||||
const save = el("button", "b08-spec__save", "일위대가 저장");
|
||||
save.type = "button";
|
||||
save.addEventListener("click", () => {
|
||||
void (async () => {
|
||||
save.disabled = true;
|
||||
status.textContent = "저장 중…";
|
||||
try {
|
||||
// 넣은 날짜는 서버가 붙임 — 값·출처만 보냄.
|
||||
const prices = Object.fromEntries(
|
||||
Object.entries(manual).map(([seq, { entered_at: _date, ...price }]) => [seq, price]),
|
||||
);
|
||||
const response = await fetch(sheetUrl(projectId, "/unit-price"), {
|
||||
method: "PUT",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sheet_key: sheetKey, rows, manual_prices: prices }),
|
||||
});
|
||||
const payload = (await response.json().catch(() => ({}))) as { message?: string };
|
||||
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
|
||||
onSaved();
|
||||
} catch (error) {
|
||||
status.textContent = `저장 못 함 — ${error instanceof Error ? error.message : ""}`;
|
||||
save.disabled = false;
|
||||
}
|
||||
})();
|
||||
});
|
||||
const close = el("button", "b08-quantity__tab", "고친 것 버리고 닫기");
|
||||
close.type = "button";
|
||||
close.addEventListener("click", onClose);
|
||||
|
||||
const actions = el("div", "b08-sheet__actions");
|
||||
actions.append(add, save, reset, close, status);
|
||||
wrap.append(
|
||||
el(
|
||||
"p",
|
||||
"b08-grid__caption",
|
||||
"줄 조합은 이 양식+프로젝트에 저장(내 라이브러리로 갈 수 있음) · 수동 단가는 이 프로젝트에만 저장",
|
||||
),
|
||||
scroller,
|
||||
picker,
|
||||
actions,
|
||||
);
|
||||
render();
|
||||
return wrap;
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
/* =============================================================================
|
||||
* B08_Quantity_UI_StructureSummary.ts
|
||||
* **구조물 집계표** 탭 — 측점별 구조물 한 줄 · 종류별 표 · 칸 고치기 (PLAN 2장).
|
||||
*
|
||||
* ⚠ 값을 셈하지 않음 — 서버(`…/quantity/structure-summary`)가 정본을 읽어 세운 줄을 적기만.
|
||||
* ⚠ 칸마다 출처 표시 — 자동(정본) · 사용자(이 표에서 고침) · 라이브러리(양식 기본값) · 빈칸.
|
||||
* ⚠ 이 표에서 고친 값을 B05 가 바꿔 자동으로 돌아간 칸은 **조용히 넘기지 않고** 줄에 알림.
|
||||
* ⚠ 고치기는 데이터 3층 — 칸 조작은 캐시(sessionStorage)에만, [저장] 때 서버가 **정본에** 씀.
|
||||
* 상세 칸만 고침 — 자리·길이·높이·관경 같은 놓기 칸은 시·종점과 한 벌이라 B05 몫(회색).
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { stationLabel } from "./B08_Quantity_UI_EarthworkGrid";
|
||||
import { el, num } from "./B08_Quantity_UI_StructureSheet_Formula";
|
||||
|
||||
type Source = "auto" | "user" | "library" | "empty";
|
||||
type CellValue = number | string | null;
|
||||
|
||||
interface SummaryCell {
|
||||
value: CellValue;
|
||||
source: Source;
|
||||
was?: CellValue;
|
||||
replaced_user_value?: CellValue;
|
||||
}
|
||||
|
||||
interface SummaryRow {
|
||||
id: string;
|
||||
origin: "structures" | "pipe_points";
|
||||
chainage_m: number | null;
|
||||
start_m: number | null;
|
||||
end_m: number | null;
|
||||
count: number;
|
||||
length_m: number | null;
|
||||
length_basis: string;
|
||||
memo: string;
|
||||
note?: string;
|
||||
replaced?: string[];
|
||||
cells: Record<string, SummaryCell>;
|
||||
}
|
||||
|
||||
interface SummaryColumn {
|
||||
key: string;
|
||||
label: string;
|
||||
unit: string;
|
||||
input: string;
|
||||
choices: string[];
|
||||
editable: boolean;
|
||||
}
|
||||
|
||||
interface SummaryTable {
|
||||
type_id: string;
|
||||
name: string;
|
||||
columns: SummaryColumn[];
|
||||
rows: SummaryRow[];
|
||||
count: number;
|
||||
length_total_m: number | null;
|
||||
length_missing: number;
|
||||
averages: Record<string, number>;
|
||||
}
|
||||
|
||||
interface SummaryResponse {
|
||||
revision: number;
|
||||
tables: SummaryTable[];
|
||||
notes: string[];
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface Edit {
|
||||
id: string;
|
||||
key: string;
|
||||
value: CellValue;
|
||||
}
|
||||
|
||||
const SOURCE_LABELS: Record<Source, string> = {
|
||||
auto: "자동 — 구조물 놓기(B05)·계곡 시설 정본 값",
|
||||
user: "사용자 — 이 표에서 고친 값(정본에 적힘)",
|
||||
library: "라이브러리 — 정본이 비어 양식 기본값으로 섬",
|
||||
empty: "빈칸 — 정본도 양식도 값 없음",
|
||||
};
|
||||
|
||||
const STYLE_ID = "b08-structure-summary-style";
|
||||
const CSS = `
|
||||
.b08-sum { display: flex; flex-direction: column; gap: 10px; min-height: 0; }
|
||||
.b08-sum__legend { display: flex; gap: 12px; flex-wrap: wrap; font-size: 12px; }
|
||||
.b08-sum__cell--auto { box-shadow: inset 3px 0 0 var(--color-border, #888); }
|
||||
.b08-sum__cell--user { box-shadow: inset 3px 0 0 var(--color-accent, #6c8ebf); }
|
||||
.b08-sum__cell--library { box-shadow: inset 3px 0 0 var(--color-success, #5cb85c); }
|
||||
.b08-sum__cell--empty { color: var(--color-text-muted, #999); }
|
||||
.b08-sum__cell--replaced { outline: 2px solid var(--color-danger, #d9534f); outline-offset: -2px; }
|
||||
.b08-sum td.is-changed { background: color-mix(in srgb, var(--color-accent, #6c8ebf) 18%, transparent); }
|
||||
.b08-sum .b08-grid__table td { white-space: nowrap; }
|
||||
.b08-sum td input, .b08-sum td select { font-size: 12px; max-width: 8rem; }
|
||||
.b08-sum td input[type=number] { width: 5rem; }
|
||||
.b08-sum td button { font-size: 11px; padding: 0 4px; margin-left: 2px; cursor: pointer; }
|
||||
.b08-sum__actions { display: flex; gap: 8px; align-items: center; }
|
||||
`;
|
||||
|
||||
function injectStyles(): void {
|
||||
if (document.getElementById(STYLE_ID)) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = STYLE_ID;
|
||||
style.textContent = CSS;
|
||||
document.head.append(style);
|
||||
}
|
||||
|
||||
/** 칸 글 — 정수는 그대로(뒷길이 45), 소수는 둘째 자리까지. */
|
||||
function cellText(value: CellValue | undefined): string {
|
||||
if (value === null || value === undefined || value === "") return "";
|
||||
return typeof value === "number" && !Number.isInteger(value) ? num(value, 2) : String(value);
|
||||
}
|
||||
|
||||
function station(row: SummaryRow): string {
|
||||
if (row.start_m !== null && row.end_m !== null) {
|
||||
return `${stationLabel(row.start_m)} ~ ${stationLabel(row.end_m)}`;
|
||||
}
|
||||
return row.chainage_m === null ? "" : stationLabel(row.chainage_m);
|
||||
}
|
||||
|
||||
/** 정본에 지금 적힌 값 — 라이브러리·빈칸은 정본이 빈 것. */
|
||||
function storedValue(cell: SummaryCell | undefined): CellValue {
|
||||
return cell && (cell.source === "auto" || cell.source === "user") ? cell.value : null;
|
||||
}
|
||||
|
||||
function same(a: CellValue, b: CellValue): boolean {
|
||||
return String(a ?? "") === String(b ?? "");
|
||||
}
|
||||
|
||||
/** 고친 칸 캐시 — [저장] 전까지 이 탭(sessionStorage)에만 둠. 판번호가 다르면 버림. */
|
||||
class Draft {
|
||||
private readonly storageKey: string;
|
||||
readonly edits = new Map<string, Edit>();
|
||||
onChange: () => void = () => undefined;
|
||||
|
||||
constructor(
|
||||
projectId: string,
|
||||
readonly revision: number,
|
||||
) {
|
||||
this.storageKey = `b08-structure-summary-draft:${projectId}`;
|
||||
try {
|
||||
const saved = JSON.parse(sessionStorage.getItem(this.storageKey) ?? "null") as {
|
||||
revision: number;
|
||||
edits: Edit[];
|
||||
} | null;
|
||||
if (saved?.revision === revision) {
|
||||
for (const edit of saved.edits) this.edits.set(`${edit.id}|${edit.key}`, edit);
|
||||
}
|
||||
} catch {
|
||||
// 캐시를 못 읽으면 빈 초안 — 정본은 그대로.
|
||||
}
|
||||
}
|
||||
|
||||
get(id: string, key: string): Edit | undefined {
|
||||
return this.edits.get(`${id}|${key}`);
|
||||
}
|
||||
|
||||
set(edit: Edit, stored: CellValue): void {
|
||||
const slot = `${edit.id}|${edit.key}`;
|
||||
if (same(edit.value, stored)) this.edits.delete(slot);
|
||||
else this.edits.set(slot, edit);
|
||||
this.persist();
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.edits.clear();
|
||||
this.persist();
|
||||
}
|
||||
|
||||
private persist(): void {
|
||||
try {
|
||||
if (this.edits.size) {
|
||||
const body = { revision: this.revision, edits: [...this.edits.values()] };
|
||||
sessionStorage.setItem(this.storageKey, JSON.stringify(body));
|
||||
} else {
|
||||
sessionStorage.removeItem(this.storageKey);
|
||||
}
|
||||
} catch {
|
||||
// 저장소가 막혀도 화면 초안은 살아 있음.
|
||||
}
|
||||
this.onChange();
|
||||
}
|
||||
}
|
||||
|
||||
function editor(
|
||||
column: SummaryColumn,
|
||||
row: SummaryRow,
|
||||
cell: SummaryCell | undefined,
|
||||
draft: Draft,
|
||||
td: HTMLTableCellElement,
|
||||
): HTMLElement[] {
|
||||
const stored = storedValue(cell);
|
||||
const pending = draft.get(row.id, column.key);
|
||||
const current = pending ? pending.value : stored;
|
||||
let control: HTMLInputElement | HTMLSelectElement;
|
||||
if (column.input === "select" && column.choices.length) {
|
||||
const select = document.createElement("select");
|
||||
select.append(new Option("—", ""));
|
||||
for (const choice of column.choices) select.append(new Option(choice, choice));
|
||||
select.value = current === null ? "" : String(current);
|
||||
control = select;
|
||||
} else {
|
||||
const input = document.createElement("input");
|
||||
input.type = column.input === "number" ? "number" : "text";
|
||||
if (column.input === "number") {
|
||||
input.min = "0";
|
||||
input.step = "any";
|
||||
}
|
||||
input.value = current === null ? "" : String(current);
|
||||
control = input;
|
||||
}
|
||||
if (cell?.source === "library") control.title = `비우면 양식 기본값 ${cellText(cell.value)}`;
|
||||
if (control instanceof HTMLInputElement && cell?.source === "library") {
|
||||
control.placeholder = cellText(cell.value);
|
||||
}
|
||||
const mark = (): void => {
|
||||
td.classList.toggle("is-changed", Boolean(draft.get(row.id, column.key)));
|
||||
};
|
||||
control.addEventListener("change", () => {
|
||||
const raw = control.value.trim();
|
||||
const value = raw === "" ? null : column.input === "number" ? Number(raw) : raw;
|
||||
draft.set({ id: row.id, key: column.key, value }, stored);
|
||||
mark();
|
||||
});
|
||||
mark();
|
||||
const parts: HTMLElement[] = [control];
|
||||
if (cell?.source === "user" && cell.was !== undefined) {
|
||||
const undo = el("button", "", "↺");
|
||||
undo.type = "button";
|
||||
undo.title = `고치기 전 값(${cellText(cell.was) || "빈칸"})으로 — [저장]하면 「자동」으로 돌아감`;
|
||||
undo.addEventListener("click", () => {
|
||||
control.value = cell.was === null || cell.was === undefined ? "" : String(cell.was);
|
||||
control.dispatchEvent(new Event("change"));
|
||||
});
|
||||
parts.push(undo);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
function renderTable(table: SummaryTable, draft: Draft): HTMLElement {
|
||||
const wrap = el("div", "b08-grid");
|
||||
const length =
|
||||
table.length_total_m === null ? "" : ` · 연장 합 ${num(table.length_total_m, 2)} m`;
|
||||
const missing = table.length_missing ? ` · 연장 빈 줄 ${table.length_missing}` : "";
|
||||
wrap.append(el("p", "b08-sheet__head", `${table.name} — ${table.count}개소${length}${missing}`));
|
||||
const scroller = el("div", "b08-grid__scroll");
|
||||
const grid = el("table", "b08-grid__table");
|
||||
const head = document.createElement("tr");
|
||||
head.append(el("th", "", "측점"), el("th", "", "연장(m)"));
|
||||
for (const column of table.columns) {
|
||||
const th = el("th", "", column.label);
|
||||
if (!column.editable) th.title = "놓기 칸 — 시·종점과 한 벌이라 구조물 놓기(B05)에서 고침";
|
||||
head.append(th);
|
||||
}
|
||||
head.append(el("th", "", "비고"));
|
||||
const thead = document.createElement("thead");
|
||||
thead.append(head);
|
||||
const tbody = document.createElement("tbody");
|
||||
for (const row of table.rows) {
|
||||
const tr = document.createElement("tr");
|
||||
const lengthCell = el("td", "", row.length_m === null ? "" : num(row.length_m, 2));
|
||||
lengthCell.title = row.length_basis || "연장 없음";
|
||||
tr.append(el("td", "", station(row)), lengthCell);
|
||||
for (const column of table.columns) {
|
||||
const cell = row.cells[column.key];
|
||||
const td = el("td", `b08-sum__cell--${cell?.source ?? "empty"}`);
|
||||
if (column.editable) td.append(...editor(column, row, cell, draft, td));
|
||||
else td.textContent = cellText(cell?.value);
|
||||
td.title = cell ? SOURCE_LABELS[cell.source] : "";
|
||||
if (cell?.source === "user" && cell.was !== undefined) {
|
||||
td.title += ` · 고치기 전 ${cellText(cell.was) || "빈칸"}`;
|
||||
}
|
||||
if (cell?.replaced_user_value !== undefined) {
|
||||
td.classList.add("b08-sum__cell--replaced");
|
||||
td.title = `이 표에서 ${cellText(cell.replaced_user_value) || "빈칸"}(으)로 고쳤으나 B05 가 바꿔 자동값으로 돌아감`;
|
||||
}
|
||||
tr.append(td);
|
||||
}
|
||||
const notes = [
|
||||
row.replaced?.length ? `⚠ B05 가 바꿈: ${row.replaced.join("·")}` : "",
|
||||
row.note ?? "",
|
||||
row.memo,
|
||||
].filter(Boolean);
|
||||
tr.append(el("td", "", notes.join(" · ")));
|
||||
tbody.append(tr);
|
||||
}
|
||||
const average = document.createElement("tr");
|
||||
average.append(el("td", "", "평균치수"), el("td", "", ""));
|
||||
for (const column of table.columns) {
|
||||
const value = table.averages[column.key];
|
||||
average.append(el("td", "", value === undefined ? "" : num(value, 2)));
|
||||
}
|
||||
average.append(el("td", "", ""));
|
||||
tbody.append(average);
|
||||
grid.append(thead, tbody);
|
||||
scroller.append(grid);
|
||||
wrap.append(scroller);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/** 탭 본문 — 받는 동안 안내, 오면 종류별 표. [저장] 뒤엔 다시 받음. */
|
||||
export function renderStructureSummary(projectId: string | null): HTMLElement {
|
||||
injectStyles();
|
||||
const root = el("div", "b08-sum");
|
||||
if (!projectId) {
|
||||
root.append(el("p", "b08-quantity__message", "프로젝트를 먼저 고를 것"));
|
||||
return root;
|
||||
}
|
||||
const url = `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/structure-summary`;
|
||||
let draft: Draft | null = null;
|
||||
// 저장 안 한 칸이 조용히 사라지지 않게 — 나갈 때 물음(자동저장은 안 만듦, CLAUDE.md 5장).
|
||||
window.addEventListener("beforeunload", (event) => {
|
||||
if (!draft?.edits.size || !root.isConnected) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = "저장 안 한 칸이 있음";
|
||||
});
|
||||
|
||||
const load = async (): Promise<void> => {
|
||||
root.replaceChildren(el("p", "b08-grid__caption", "구조물 집계표 불러오는 중…"));
|
||||
try {
|
||||
const response = await fetch(url, { credentials: "include" });
|
||||
const payload = (await response.json().catch(() => ({}))) as SummaryResponse;
|
||||
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
|
||||
const active = new Draft(projectId, payload.revision);
|
||||
draft = active;
|
||||
const legend = el("div", "b08-sum__legend");
|
||||
for (const source of ["auto", "user", "library", "empty"] as Source[]) {
|
||||
legend.append(el("span", `b08-sum__cell--${source}`, ` ${SOURCE_LABELS[source]} `));
|
||||
}
|
||||
const save = el("button", "b08-spec__save", "저장");
|
||||
save.type = "button";
|
||||
const discard = el("button", "b08-quantity__tab", "고친 것 버리기");
|
||||
discard.type = "button";
|
||||
const status = el("span", "b08-grid__caption");
|
||||
active.onChange = () => {
|
||||
const count = active.edits.size;
|
||||
save.disabled = count === 0;
|
||||
discard.disabled = count === 0;
|
||||
status.textContent = count
|
||||
? `고친 칸 ${count} — [저장]해야 정본에 적힘(구조물도·원단위가 그 값을 씀)`
|
||||
: "상세 칸만 고침 · 자리·길이·높이·관경은 구조물 놓기(B05)에서";
|
||||
};
|
||||
active.onChange();
|
||||
save.addEventListener("click", () => {
|
||||
void (async () => {
|
||||
save.disabled = true;
|
||||
status.textContent = "저장 중…";
|
||||
try {
|
||||
const result = await fetch(url, {
|
||||
method: "PUT",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
base_revision: active.revision,
|
||||
edits: [...active.edits.values()],
|
||||
}),
|
||||
});
|
||||
const body = (await result.json().catch(() => ({}))) as { message?: string };
|
||||
if (!result.ok) throw new Error(body.message ?? `HTTP ${result.status}`);
|
||||
active.clear();
|
||||
await load();
|
||||
} catch (error) {
|
||||
status.textContent = `저장 못 함 — ${error instanceof Error ? error.message : ""}`;
|
||||
save.disabled = false;
|
||||
}
|
||||
})();
|
||||
});
|
||||
discard.addEventListener("click", () => {
|
||||
active.clear();
|
||||
void load();
|
||||
});
|
||||
const actions = el("div", "b08-sum__actions");
|
||||
actions.append(save, discard, status);
|
||||
const total = payload.tables.reduce((sum, table) => sum + table.count, 0);
|
||||
root.replaceChildren(
|
||||
el(
|
||||
"p",
|
||||
"b08-sheet__head",
|
||||
`구조물 집계표 · ${payload.tables.length}종 · ${total}개소 (측점별 실치수 — 구조물도가 이 값을 씀)`,
|
||||
),
|
||||
legend,
|
||||
actions,
|
||||
...payload.notes.map((note) => el("p", "b08-grid__caption b08-grid__caption--warn", note)),
|
||||
...(payload.tables.length
|
||||
? payload.tables.map((table) => renderTable(table, active))
|
||||
: [el("p", "b08-grid__caption", "놓인 구조물이 없음")]),
|
||||
);
|
||||
} catch (error) {
|
||||
root.replaceChildren(
|
||||
el(
|
||||
"p",
|
||||
"b08-grid__caption b08-grid__caption--warn",
|
||||
`구조물 집계표를 불러오지 못함 — ${error instanceof Error ? error.message : ""}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
void load();
|
||||
return root;
|
||||
}
|
||||
@@ -236,6 +236,8 @@ export interface PreparationRow {
|
||||
reason: string;
|
||||
reference_amount?: number;
|
||||
work_item_code: string | null;
|
||||
/** 수동 단가로 선 금액 수 — 「미확정 N건」·빨간 테두리(임목폐기물 처리). */
|
||||
unconfirmed?: number;
|
||||
}
|
||||
|
||||
export interface PreparationTable {
|
||||
@@ -260,6 +262,14 @@ export function renderPreparationGrid(
|
||||
const caption = document.createElement("p");
|
||||
caption.className = "b08-grid__caption";
|
||||
caption.textContent = `${table.row_count}줄 · 값을 낼 근거가 아직 없는 줄 ${table.pending_count}개`;
|
||||
const unconfirmed = table.rows.reduce((sum, row) => sum + (row.unconfirmed ?? 0), 0);
|
||||
if (unconfirmed) {
|
||||
const badge = document.createElement("span");
|
||||
badge.style.cssText =
|
||||
"margin-left:8px;padding:0 6px;border-radius:8px;color:#fff;background:var(--color-danger,#d9534f);font-size:12px";
|
||||
badge.textContent = `미확정 ${unconfirmed}건`;
|
||||
caption.append(badge);
|
||||
}
|
||||
wrap.append(caption);
|
||||
|
||||
const scroller = document.createElement("div");
|
||||
@@ -291,6 +301,8 @@ export function renderPreparationGrid(
|
||||
if (row.reference_amount) {
|
||||
note.append(document.createTextNode(` (참고 면적 ${num(row.reference_amount, 1)})`));
|
||||
}
|
||||
// 수동 단가로 선 금액이 든 줄 — 빨간 테두리(미확정).
|
||||
if (row.unconfirmed) note.style.outline = "2px solid var(--color-danger, #d9534f)";
|
||||
tr.append(note);
|
||||
// 값을 못 세운 줄의 「수량」은 **막힘** — 근거가 오면 채워질 자리라 제외과 갈라 보인다.
|
||||
markRow(tr, PREPARATION_KEYS, row.amount === null ? { 3: "blocked" } : undefined);
|
||||
|
||||
@@ -28,6 +28,7 @@ TYPE_LABELS = {
|
||||
"pipe": "배수관",
|
||||
"pipe_inlet_basin": "배수관 유입부 집수정",
|
||||
"ford_pavement": "물넘이포장",
|
||||
"open_ditch": "개거(겉도랑)",
|
||||
"ford_bridge": "세월교",
|
||||
"box_culvert": "BOX암거",
|
||||
# 사방 시설 — 레지스트리 이름 그대로 옮긴 것(2026-09-08 확인). 지어낸 이름 아님.
|
||||
@@ -44,6 +45,8 @@ OPTION_LABELS = {
|
||||
"form": ("옹벽 형식", "구조물 상세 입력"),
|
||||
"height_m": ("높이(m)", "구조물 배치"),
|
||||
"length_m": ("연장(m)", "구조물 배치"),
|
||||
# 물넘이포장(관 지점 시설) — B05 시설 칸 · 구조물 집계표에서 적음(2026-09-14 A3).
|
||||
"thickness_cm": ("포장 두께(㎝)", "구조물 배치 물넘이포장 칸 · 구조물 집계표"),
|
||||
# 2026-09-09 칸이 생겼음(`ebdf2988`) — 「아직 칸이 없음」이 거짓이 되어 고침.
|
||||
# ⚠ 비워 두면 품셈 표준경사표로 자동 판정된다(확정 ⑨).
|
||||
"face_slope_ratio": ("전면 기울기", "구조물 상세 입력 — 비우면 품셈 표준경사로 자동"),
|
||||
|
||||
@@ -85,6 +85,7 @@ def chosen_conditions(settings: dict[str, Any] | None) -> list[dict[str, str]]:
|
||||
("fuel_region", "유가 지역(시도코드)"),
|
||||
("transport_distance_km", "기계 수송 거리(편도 ㎞)"),
|
||||
("transport_road", "수송 도로 구분"),
|
||||
("transport_trips", "기계 수송 회수(대수 × 왕복)"),
|
||||
):
|
||||
value = str(picked.get(key) or "").strip()
|
||||
if value:
|
||||
@@ -110,11 +111,19 @@ def work_item_basis(build: Any) -> list[dict[str, Any]]:
|
||||
for code, title in sorted(build.book.titles.items()):
|
||||
if title.kind is not PriceKind.UNIT_PRICE:
|
||||
continue
|
||||
notes = [
|
||||
detail.note.strip()
|
||||
for detail in build.book.details.get(code, [])
|
||||
if detail.note and detail.note.strip()
|
||||
own = build.book.details.get(code, [])
|
||||
# Q 식 문구는 층 차례 뒤 D(단가산출) 줄에 삶 — 그 일위대가가 부르는 D 의 줄 문구도 함께(명세 3장).
|
||||
basis = [
|
||||
detail
|
||||
for ref in own
|
||||
if ref.ref_code.startswith("D-")
|
||||
for detail in build.book.details.get(ref.ref_code, [])
|
||||
]
|
||||
notes = []
|
||||
for detail in [*own, *basis]:
|
||||
text = detail.note.strip() if detail.note else ""
|
||||
if text and text not in notes:
|
||||
notes.append(text)
|
||||
work_item_code = code[2:].split("#")[0]
|
||||
source = build.factor_sources.get(work_item_code)
|
||||
if source and source not in notes:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user