Compare commits
107
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
329bdc5c90 | ||
|
|
ab8a010985 | ||
|
|
30fc514f3f | ||
|
|
cbd3b87ed5 | ||
|
|
3d896dec50 | ||
|
|
527c1929cb | ||
|
|
0667274733 | ||
|
|
078af37d33 | ||
|
|
3cfe3ef58a | ||
|
|
d54d52b6eb | ||
|
|
9f1adeab49 | ||
|
|
05e88998c1 | ||
|
|
0781e6c379 | ||
|
|
a3950a189b | ||
|
|
387af3d888 | ||
|
|
42e8b4f0a9 | ||
|
|
d64becb12c | ||
|
|
e0c5e36433 | ||
|
|
15551b9ea1 | ||
|
|
ea81b7644e | ||
|
|
e40f4c9fd3 | ||
|
|
b9df49d1dd | ||
|
|
9563d0604a | ||
|
|
e15e9fc223 | ||
|
|
957d727a24 | ||
|
|
a314ac12fd | ||
|
|
852ad6c7d8 | ||
|
|
6152437113 | ||
|
|
2f9d42ac35 | ||
|
|
80b2669638 | ||
|
|
7dd5dda98b | ||
|
|
f672c5bee6 | ||
|
|
0fc64fa704 | ||
|
|
490cfe1d08 | ||
|
|
08ff9458ea | ||
|
|
c73654be66 | ||
|
|
16d3461573 | ||
|
|
364c28a700 | ||
|
|
4d33f8d61c | ||
|
|
ac66a25cc7 | ||
|
|
cd6c7f3fed | ||
|
|
b34eb546f9 | ||
|
|
2ad20e5d02 | ||
|
|
9deaf479d0 | ||
|
|
419148901b | ||
|
|
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 | ||
|
|
fc825dc9d3 |
@@ -40,6 +40,8 @@ export interface StructureOptionField {
|
|||||||
warn_message?: string | null;
|
warn_message?: string | null;
|
||||||
/** 원단위 표가 아직 안 읽는 칸 — 칸 이름 옆 「표에 안 쓰임」 + 툴팁 사유. */
|
/** 원단위 표가 아직 안 읽는 칸 — 칸 이름 옆 「표에 안 쓰임」 + 툴팁 사유. */
|
||||||
not_in_table?: string | null;
|
not_in_table?: string | null;
|
||||||
|
/** 기본값의 뜻(도메인 확정값이 아닐 때) — 칸 밑 근거 한 줄. */
|
||||||
|
default_basis?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 구조물 배치 폼을 어느 화면이 쓰는가 — B05 는 유무·종류·위치만, **B06/B07 은 상세
|
/** 구조물 배치 폼을 어느 화면이 쓰는가 — B05 는 유무·종류·위치만, **B06/B07 은 상세
|
||||||
|
|||||||
@@ -240,6 +240,65 @@
|
|||||||
"default": 2.0,
|
"default": 2.0,
|
||||||
"required": false
|
"required": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"key": "length_m",
|
||||||
|
"label": "연장(계류 방향)",
|
||||||
|
"input": "number",
|
||||||
|
"unit": "m",
|
||||||
|
"default": null,
|
||||||
|
"required": false,
|
||||||
|
"empty_means": "비우면 수량이 안 섬 — 구체 길이는 설계자 입력(기본값 없음 · 2026-09-15 브레인 판정 ③)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "wall_thickness_m",
|
||||||
|
"label": "측벽 두께",
|
||||||
|
"input": "number",
|
||||||
|
"unit": "m",
|
||||||
|
"default": 0.3,
|
||||||
|
"default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 벽 0.3 · KDS 44 90 00 4.5.2 도로암거 부재 최소두께 300㎜ · 바꿀 수 있음",
|
||||||
|
"required": false,
|
||||||
|
"phase": "detail"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "slab_thickness_m",
|
||||||
|
"label": "상·저판 두께",
|
||||||
|
"input": "number",
|
||||||
|
"unit": "m",
|
||||||
|
"default": 0.25,
|
||||||
|
"default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 판 0.25 — ⚠ KDS 44 90 00 4.5.2 도로암거 부재 최소두께 300㎜ 에 못 미침(임도 예제 0.25 · KDS 도로 최소 300㎜) · 설계자가 고름",
|
||||||
|
"required": false,
|
||||||
|
"phase": "detail"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "haunch_m",
|
||||||
|
"label": "헌치(모따기) 크기",
|
||||||
|
"input": "number",
|
||||||
|
"unit": "m",
|
||||||
|
"default": 0.2,
|
||||||
|
"default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 0.2×0.2 · 실무 울진 기번3 구조도 2×2 도 0.2 · 바꿀 수 있음",
|
||||||
|
"required": false,
|
||||||
|
"phase": "detail"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "blinding_thickness_m",
|
||||||
|
"label": "기초(버림) 두께",
|
||||||
|
"input": "number",
|
||||||
|
"unit": "m",
|
||||||
|
"default": 0.1,
|
||||||
|
"default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 기초 0.1(양쪽 0.1 여유) · 실무 울진 기번3 구조도 2×2 버림 0.1 · 바꿀 수 있음",
|
||||||
|
"required": false,
|
||||||
|
"phase": "detail"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "trench_depth_m",
|
||||||
|
"label": "평균 터파기고",
|
||||||
|
"input": "number",
|
||||||
|
"unit": "m",
|
||||||
|
"default": null,
|
||||||
|
"required": false,
|
||||||
|
"phase": "detail",
|
||||||
|
"empty_means": "비우면 구체 터파기·되메우기가 안 섬 — 원지반에서 터파기 바닥(기초잡석 밑)까지 · 토공집계로 감(2026-09-15 브레인 판정 ③)"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"key": "wing_in",
|
"key": "wing_in",
|
||||||
"label": "날개벽(유입)",
|
"label": "날개벽(유입)",
|
||||||
@@ -727,7 +786,7 @@
|
|||||||
"input": "number",
|
"input": "number",
|
||||||
"unit": "m",
|
"unit": "m",
|
||||||
"default": 2.0,
|
"default": 2.0,
|
||||||
"default_basis": "제안값 2.0 — 관측 원단위 자료가 반중력식 H=2.0 한 벌뿐이라 그 값(2026-09-14 A4 · 옛 2.5 는 자료 없음)",
|
"default_basis": "기본값 · 소광리 도면 H=2.0 · 바꿀 수 있음",
|
||||||
"required": false,
|
"required": false,
|
||||||
"phase": "b05",
|
"phase": "b05",
|
||||||
"empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)"
|
"empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)"
|
||||||
@@ -2473,8 +2532,58 @@
|
|||||||
"input": "number",
|
"input": "number",
|
||||||
"unit": "cm",
|
"unit": "cm",
|
||||||
"default": null,
|
"default": null,
|
||||||
"required": true,
|
"required": false,
|
||||||
|
"phase": "detail",
|
||||||
|
"empty_means": "비우면 B06 포장층 두께(표준 횡단면 설정 · 기본 0.2m = 임도기술교본 3-2 「두께 0.2m」)를 이음 — 적으면 적은 값이 이김"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "width_m",
|
||||||
|
"label": "포장 폭",
|
||||||
|
"input": "number",
|
||||||
|
"unit": "m",
|
||||||
|
"default": null,
|
||||||
|
"required": false,
|
||||||
|
"phase": "detail",
|
||||||
|
"empty_means": "비우면 B06 노폭(구간 측점의 차도 표준 폭 · 교본 3-2 「포장 폭 3.0m」 · 실무 관측 3.5~4.0)을 이음 — 적으면 적은 값이 이김"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "widening_area_m2",
|
||||||
|
"label": "확폭 면적",
|
||||||
|
"input": "number",
|
||||||
|
"unit": "㎡",
|
||||||
|
"default": null,
|
||||||
|
"required": false,
|
||||||
|
"phase": "detail",
|
||||||
|
"empty_means": "비우면 B06 곡선부 확폭을 측점 사이 평균 × 거리로 더함(교본 3-2 「곡선부에는 확폭」) — 확폭이 없으면 0 을 적을 것"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "joint_spacing_m",
|
||||||
|
"label": "수축줄눈 간격",
|
||||||
|
"input": "number",
|
||||||
|
"unit": "m",
|
||||||
|
"default": 6,
|
||||||
|
"default_basis": "제안값 · 임도기술교본 부록 4-7 7-1 3.5.1 「수축줄눈의 간격은 4-6m를 기준」 · 실무 소광 6m 관측 · 바꿀 수 있음",
|
||||||
"phase": "detail"
|
"phase": "detail"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "separation_sheet",
|
||||||
|
"label": "비닐(깔기·양생)",
|
||||||
|
"input": "select",
|
||||||
|
"choices": ["있음", "없음"],
|
||||||
|
"default": null,
|
||||||
|
"required": false,
|
||||||
|
"phase": "detail",
|
||||||
|
"empty_means": "비우면 비닐 재료가 안 서고 사유가 뜸 — 품셈 12-6 [주]② 「양생 재료비(비닐 등)는 별도 계상」이라 설계자가 고름(기본값 없음)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "wire_mesh",
|
||||||
|
"label": "철망",
|
||||||
|
"input": "select",
|
||||||
|
"choices": ["있음", "없음"],
|
||||||
|
"default": null,
|
||||||
|
"required": false,
|
||||||
|
"phase": "detail",
|
||||||
|
"empty_means": "비우면 철망 재료가 안 서고 사유가 뜸 — 품셈 12-6 [주]② 「철망재료비는 별도 계상」 · 교본 부록 7-1 3.4 「설계도서에 따라」 · 실무 소광 「철망포함」 관측"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -361,10 +361,17 @@ export function createFacilityOptionsForm(
|
|||||||
emit();
|
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) —
|
// 세월교·물넘이 항목은 **관종·관경 바로 다음**에 둔다(2026-08-30 사용자 지시 2) —
|
||||||
// 월류 폭·높이 → 바닥 경사 → 수량 → 개략 단면 결과. 다른 시설에서는 전부 숨는다.
|
// 월류 폭·높이 → 바닥 경사 → 수량 → 개략 단면 결과. 다른 시설에서는 전부 숨는다.
|
||||||
root.append(
|
root.append(
|
||||||
grid(suggest),
|
grid(suggest),
|
||||||
|
saveNote,
|
||||||
pipeRow,
|
pipeRow,
|
||||||
wingRow,
|
wingRow,
|
||||||
ford.widthRow,
|
ford.widthRow,
|
||||||
|
|||||||
@@ -9,12 +9,23 @@
|
|||||||
|
|
||||||
import { chainageToStation, stationToChainage } from "./B05_Profile_Util_Station";
|
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");
|
const wrapper = document.createElement("label");
|
||||||
wrapper.className = "b05-route__field";
|
wrapper.className = "b05-route__field";
|
||||||
const caption = document.createElement("span");
|
const caption = document.createElement("span");
|
||||||
caption.textContent = labelText;
|
caption.textContent = labelText;
|
||||||
wrapper.append(caption, input);
|
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;
|
return wrapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +56,8 @@ interface OptionShape {
|
|||||||
warn_message?: string | null;
|
warn_message?: string | null;
|
||||||
/** 원단위 표가 아직 안 읽는 칸 — 툴팁이 이 사유를 먼저 보임. */
|
/** 원단위 표가 아직 안 읽는 칸 — 툴팁이 이 사유를 먼저 보임. */
|
||||||
not_in_table?: string | null;
|
not_in_table?: string | null;
|
||||||
|
/** 기본값의 뜻 — 칸 밑 근거 한 줄. */
|
||||||
|
default_basis?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -332,7 +332,7 @@ export function createStructuresSection(
|
|||||||
input.classList.add("is-locked");
|
input.classList.add("is-locked");
|
||||||
input.title = "지금은 고를 수 없는 항목입니다.";
|
input.title = "지금은 고를 수 없는 항목입니다.";
|
||||||
}
|
}
|
||||||
optionRow.append(field(label, input));
|
optionRow.append(field(label, input, option.default_basis));
|
||||||
input.addEventListener("change", () => liveCommit());
|
input.addEventListener("change", () => liveCommit());
|
||||||
optionInputs.push({
|
optionInputs.push({
|
||||||
key: option.key,
|
key: option.key,
|
||||||
|
|||||||
@@ -214,6 +214,18 @@
|
|||||||
line-height: 1.5;
|
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행. 한 행은 [라벨][측점][+거리] 가로 배치
|
/* 시작·기준·종료 측점 = 3행. 한 행은 [라벨][측점][+거리] 가로 배치
|
||||||
* (2026-08-17 사용자 지시 2). */
|
* (2026-08-17 사용자 지시 2). */
|
||||||
.b05-structure__position-row {
|
.b05-structure__position-row {
|
||||||
|
|||||||
@@ -110,8 +110,9 @@ FORD_DEFAULT_WIDTH_M = 10.0
|
|||||||
# 지식DB 폭 수치 근거 없음).
|
# 지식DB 폭 수치 근거 없음).
|
||||||
FORD_PAVEMENT_DEFAULT_WIDTH_M = 5.0
|
FORD_PAVEMENT_DEFAULT_WIDTH_M = 5.0
|
||||||
|
|
||||||
# BOX암거 부재 두께(m) — 지식DB에 암거 부재 두께 기준이 없어 세월교 값을 승계한다
|
# BOX암거 부재 두께 — **등록부 칸**(`wall_thickness_m`·`slab_thickness_m`)이 정본이다
|
||||||
# (2026-08-25 사용자 확정). 측벽·상판·저판은 각각 벽 두께·바닥판 두께를 그대로 쓴다.
|
# (2026-09-15 브레인 판정 ① · 제안값 = 산림과임업기술(임도) 예제 벽 0.3·판 0.25 · KDS 300㎜ 병기).
|
||||||
|
# 종전 「세월교 값 승계」(2026-08-25 사용자 확정)는 근거 없는 값이라 걷음 — 수량·그림이 한 칸을 읽음.
|
||||||
# ⚠ 교차 참조 ④ 암거 위 복토(m) — 별표2 교량·암거 "복토 시 흙 두께 50㎝ 이상".
|
# ⚠ 교차 참조 ④ 암거 위 복토(m) — 별표2 교량·암거 "복토 시 흙 두께 50㎝ 이상".
|
||||||
BOX_COVER_M = 0.5
|
BOX_COVER_M = 0.5
|
||||||
|
|
||||||
@@ -451,8 +452,10 @@ def _box_set(options: dict[str, Any] | None) -> dict[str, Any]:
|
|||||||
values = dict(options or {})
|
values = dict(options or {})
|
||||||
inner_width = _number(values.get("body_width_m"), _number(defaults.get("body_width_m"), 2.0))
|
inner_width = _number(values.get("body_width_m"), _number(defaults.get("body_width_m"), 2.0))
|
||||||
inner_height = _number(values.get("body_height_m"), _number(defaults.get("body_height_m"), 2.0))
|
inner_height = _number(values.get("body_height_m"), _number(defaults.get("body_height_m"), 2.0))
|
||||||
wall = FORD_WALL_THICKNESS_M
|
wall = _number(values.get("wall_thickness_m"), _number(defaults.get("wall_thickness_m"), None))
|
||||||
slab = FORD_SLAB_THICKNESS_M
|
slab = _number(values.get("slab_thickness_m"), _number(defaults.get("slab_thickness_m"), None))
|
||||||
|
wall = wall if wall and wall > 0 else FORD_WALL_THICKNESS_M # 등록부가 비었을 때만(옛 판)
|
||||||
|
slab = slab if slab and slab > 0 else FORD_SLAB_THICKNESS_M
|
||||||
wing_in = _wing_spec(values, defaults, "in")
|
wing_in = _wing_spec(values, defaults, "in")
|
||||||
wing_out = _wing_spec(values, defaults, "out")
|
wing_out = _wing_spec(values, defaults, "out")
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -83,6 +83,8 @@ if (input.haul_plan_for) {
|
|||||||
structure_spoil_points: input.context?.structure_spoil_points ?? null,
|
structure_spoil_points: input.context?.structure_spoil_points ?? null,
|
||||||
// 잔토는 자연상태로 오고 곡선은 다짐상태다 — 담기 전에 ×C 하는 데 쓴다.
|
// 잔토는 자연상태로 오고 곡선은 다짐상태다 — 담기 전에 ×C 하는 데 쓴다.
|
||||||
conversion: input.context?.earthwork_conversion ?? null,
|
conversion: input.context?.earthwork_conversion ?? null,
|
||||||
|
// 화면이 그리는 계획 — 잔진동을 거른다(수량은 저장 정본 `haul_plan` 이 따로 낸다).
|
||||||
|
drawing: true,
|
||||||
});
|
});
|
||||||
writeFileSync(outputPath, JSON.stringify({ haul_plan: plan ?? null }));
|
writeFileSync(outputPath, JSON.stringify({ haul_plan: plan ?? null }));
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
@@ -104,18 +106,27 @@ const result = conversion
|
|||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
// 배분은 **서버만** 만든다 — 그래야 그 코드가 브라우저 번들에서 빠진다(2026-09-06).
|
// 배분은 **서버만** 만든다 — 그래야 그 코드가 브라우저 번들에서 빠진다(2026-09-06).
|
||||||
const plan = result
|
// 두 벌을 남긴다 — `haul_plan` 은 **거르지 않은** 수량 정본(B08), `haul_plan_drawing` 은
|
||||||
? computeHaulPlan(result, input.context?.haul_equipment_limits, {
|
// 잔진동을 거른 그림(B07 토적도). 거르기가 수량에 닿으면 운반이 사라진다(2026-09-14 브레인 ①).
|
||||||
collected_stone_deduction_m3: input.context?.collected_stone_deduction_m3 ?? null,
|
const planFor = (drawing: boolean) =>
|
||||||
collected_stone_by_ground_m3: input.context?.collected_stone_by_ground_m3 ?? null,
|
result
|
||||||
collected_stone_ground_unknown_m3: input.context?.collected_stone_ground_unknown_m3 ?? null,
|
? computeHaulPlan(result, input.context?.haul_equipment_limits, {
|
||||||
structure_spoil_m3: input.context?.structure_spoil_m3 ?? null,
|
collected_stone_deduction_m3: input.context?.collected_stone_deduction_m3 ?? null,
|
||||||
structure_spoil_points: input.context?.structure_spoil_points ?? null,
|
collected_stone_by_ground_m3: input.context?.collected_stone_by_ground_m3 ?? null,
|
||||||
conversion: conversion ?? null,
|
collected_stone_ground_unknown_m3: input.context?.collected_stone_ground_unknown_m3 ?? null,
|
||||||
})
|
structure_spoil_m3: input.context?.structure_spoil_m3 ?? null,
|
||||||
: 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
|
const massHaul = result
|
||||||
? massHaulPayload(result, plan ? { haul_plan: haulPlanPayload(plan) } : null)
|
? massHaulPayload(result, {
|
||||||
|
...(plan ? { haul_plan: haulPlanPayload(plan) } : {}),
|
||||||
|
...(drawingPlan ? { haul_plan_drawing: haulPlanPayload(drawingPlan) } : {}),
|
||||||
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// 선 다단 벽 목록(④) — 관 연장처럼 기하가 세운 결과를 정본에 남겨 B08 이 줄을 세움.
|
// 선 다단 벽 목록(④) — 관 연장처럼 기하가 세운 결과를 정본에 남겨 B08 이 줄을 세움.
|
||||||
|
|||||||
@@ -39,11 +39,12 @@ from B06_Section.B06_Section_Repository import (
|
|||||||
from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs
|
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_node_bundle import run_bundle_json
|
||||||
from common_util.common_util_project_settings import (
|
from common_util.common_util_project_settings import (
|
||||||
earthwork_conversion_factors,
|
|
||||||
haul_equipment_limits,
|
haul_equipment_limits,
|
||||||
|
mixed_conversion_factors,
|
||||||
quantity_settings,
|
quantity_settings,
|
||||||
)
|
)
|
||||||
from common_util.common_util_storage import resolve_stored_project_path
|
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_db import get_db_pool, run_with_connection
|
||||||
from config.config_system import (
|
from config.config_system import (
|
||||||
EARTHWORK_CONVERSION_FACTORS,
|
EARTHWORK_CONVERSION_FACTORS,
|
||||||
@@ -96,7 +97,8 @@ async def conversion_factors_for(project_id: Any) -> dict[str, dict[str, float]]
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("B06 프로젝트 경로를 못 찾음 — 기본 계수로 진행: project_id=%s", project_id)
|
logger.warning("B06 프로젝트 경로를 못 찾음 — 기본 계수로 진행: project_id=%s", project_id)
|
||||||
return {kind: dict(entry) for kind, entry in EARTHWORK_CONVERSION_FACTORS.items()}
|
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]]:
|
async def haul_limits_for(project_id: Any) -> list[tuple[str, float | None]]:
|
||||||
@@ -153,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(
|
def _enforce_stored_designs(
|
||||||
longitudinal: dict[str, Any],
|
longitudinal: dict[str, Any],
|
||||||
sections: list[dict[str, Any]],
|
sections: list[dict[str, Any]],
|
||||||
project_root: Path,
|
project_root: Path,
|
||||||
standard: dict[str, Any] | None,
|
standard: dict[str, Any] | None,
|
||||||
|
sampler: Any = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""저장분 설계를 **쓰는 시점에** 바로잡는다 — 포장 구간·세월교 노면 하강.
|
"""저장분 설계를 **쓰는 시점에** 바로잡는다 — 포장 구간·세월교 노면 하강.
|
||||||
|
|
||||||
예전에는 상세를 **읽을 때마다** 돌려 화면이 볼 때만 맞았다(저장분은 낡은 채로).
|
예전에는 상세를 **읽을 때마다** 돌려 화면이 볼 때만 맞았다(저장분은 낡은 채로).
|
||||||
2026-09-06 사용자 확정대로 「읽기는 영구저장소에서 가져오기만」이므로 이쪽으로 옮겼다.
|
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_Engine_SpoilFill import enforce_spoil_fills
|
||||||
from B06_Section.B06_Section_Router_Design import (
|
from B06_Section.B06_Section_Router_Design import (
|
||||||
enforce_ford_surface_drops,
|
enforce_ford_surface_drops,
|
||||||
enforce_pavement_ranges,
|
enforce_pavement_ranges,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ⚠ 미교차 샘플 넓힘이 **맨 앞**이다 — 뒤의 보정들이 넓힌 지반 위에서 다시 계산해야 한다(㉳ (가)).
|
||||||
|
extend_unclosed_sections(longitudinal, sections, project_root, standard, sampler)
|
||||||
enforce_pavement_ranges(longitudinal, sections, project_root, standard)
|
enforce_pavement_ranges(longitudinal, sections, project_root, standard)
|
||||||
enforce_ford_surface_drops(longitudinal, sections, project_root, standard)
|
enforce_ford_surface_drops(longitudinal, sections, project_root, standard)
|
||||||
# ⚠ 사토장은 **맨 뒤**다 — 앞의 두 보정이 설계를 다시 계산하면서 사토장 칸을 지운다.
|
# ⚠ 사토장은 **맨 뒤**다 — 앞의 두 보정이 설계를 다시 계산하면서 사토장 칸을 지운다.
|
||||||
@@ -197,11 +219,12 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
|
|||||||
pool = get_db_pool()
|
pool = get_db_pool()
|
||||||
# 구조물 몫(채집석 공제·구조물 잔토)도 함께 받아 온다 — **B08 이 낸 값**이고, 안 넘기면
|
# 구조물 몫(채집석 공제·구조물 잔토)도 함께 받아 온다 — **B08 이 낸 값**이고, 안 넘기면
|
||||||
# 통로만 있고 값이 안 흐른다(2026-09-09 실측: 공제가 늘 `None` 이라 사토가 안 줄었다).
|
# 통로만 있고 값이 안 흐른다(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),
|
get_section_detail(project_uuid, route_id),
|
||||||
run_with_connection(get_project_storage_relative_path, project_uuid),
|
run_with_connection(get_project_storage_relative_path, project_uuid),
|
||||||
run_with_connection(get_longitudinal_section, project_uuid, route_id),
|
run_with_connection(get_longitudinal_section, project_uuid, route_id),
|
||||||
haul_inputs_for(project_uuid),
|
haul_inputs_for(project_uuid),
|
||||||
|
run_with_connection(get_surface_confirmation_params, str(project_uuid)),
|
||||||
)
|
)
|
||||||
marks.append(("종횡단 상세+DB 조회(병렬)", time.perf_counter()))
|
marks.append(("종횡단 상세+DB 조회(병렬)", time.perf_counter()))
|
||||||
payload = getattr(response, "model_dump", None)
|
payload = getattr(response, "model_dump", None)
|
||||||
@@ -216,8 +239,16 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
|
|||||||
standard = stored_standard_cross_section(longitudinal_row)
|
standard = stored_standard_cross_section(longitudinal_row)
|
||||||
# 포장 구간·세월교 보정 — 고쳐진 설계 위에서 면적·유토곡선이 나와야 한다.
|
# 포장 구간·세월교 보정 — 고쳐진 설계 위에서 면적·유토곡선이 나와야 한다.
|
||||||
before = [json.dumps(item.get("design"), sort_keys=True, default=str) for item in sections]
|
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(
|
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 = [
|
fixed = [
|
||||||
item
|
item
|
||||||
@@ -235,7 +266,7 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
|
|||||||
"detail": detail,
|
"detail": detail,
|
||||||
"context": _mass_haul_context(
|
"context": _mass_haul_context(
|
||||||
haul_inputs,
|
haul_inputs,
|
||||||
earthwork_conversion_factors(quantity_settings(project_root)),
|
mixed_conversion_factors(quantity_settings(project_root)),
|
||||||
haul_equipment_limits(quantity_settings(project_root)),
|
haul_equipment_limits(quantity_settings(project_root)),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* B06_Section_UI_Cross_FillSlope_Notice.ts
|
||||||
|
* 성토사면 5m 초과 **경고 줄** — 횡단 카드 목록 위 요약 한 줄 + 펼치면 측점 목록
|
||||||
|
* (2026-09-14 브레인 승인 (나)). 판정은 `_Cross_FillSlope_Warn`, 여기는 그리기만.
|
||||||
|
* 측점을 누르면 그 카드로 간다. 경고만 — 구조물을 세우거나 값을 바꾸지 않는다.
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import type { CrossSection } from "./B06_Section_Api_Fetch";
|
||||||
|
import { fillSlopeLengths } from "./B06_Section_UI_Cross_Fit";
|
||||||
|
import { FILL_SLOPE_WARN_TEXT, fillSlopeWarnings } from "./B06_Section_UI_Cross_FillSlope_Warn";
|
||||||
|
import { L, stationLabel } from "./B06_Section_UI_Section_Common";
|
||||||
|
|
||||||
|
export interface FillSlopeNotice {
|
||||||
|
root: HTMLDetailsElement;
|
||||||
|
/** 측점 설계가 바뀔 때마다 부른다 — 펼침 상태는 그대로 둔다. */
|
||||||
|
update: (sections: ReadonlyArray<CrossSection>, stationInterval: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFillSlopeNotice(onPick: (stationId: string) => void): FillSlopeNotice {
|
||||||
|
const root = document.createElement("details");
|
||||||
|
root.className = "b06-section__notice";
|
||||||
|
root.hidden = true;
|
||||||
|
const summary = document.createElement("summary");
|
||||||
|
const list = document.createElement("div");
|
||||||
|
list.className = "b06-section__notice-list";
|
||||||
|
root.append(summary, list);
|
||||||
|
|
||||||
|
return {
|
||||||
|
root,
|
||||||
|
update(sections, stationInterval) {
|
||||||
|
const warnings = fillSlopeWarnings(sections, fillSlopeLengths);
|
||||||
|
root.hidden = !warnings.length;
|
||||||
|
summary.textContent = `⚠ ${FILL_SLOPE_WARN_TEXT} · ${warnings.length}측점`;
|
||||||
|
list.replaceChildren(
|
||||||
|
...warnings.map(({ section, sides }) => {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
const parts = sides.map(({ side, lengthM, open }) => {
|
||||||
|
const label = L(side === "left" ? "B06_Design_Ditch_Left" : "B06_Design_Ditch_Right");
|
||||||
|
return `${label} ${open ? "≥" : ""}${lengthM.toFixed(2)}m`;
|
||||||
|
});
|
||||||
|
button.textContent = `${stationLabel(section.chainage_m, stationInterval)} ${parts.join(" · ")}`;
|
||||||
|
button.addEventListener("click", () => onPick(section.station_id));
|
||||||
|
return button;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* B06_Section_UI_Cross_FillSlope_Warn.ts
|
||||||
|
* 성토사면 길이 5m 초과 **경고** 판정(2026-09-14 브레인 승인 (나)) — 값만 가리고 그리지 않는다.
|
||||||
|
*
|
||||||
|
* 근거 — 산림자원법 시행규칙 별표2 Ⅰ.2.차.(3).(나) · 임도설치 규정 별표7 2.차.(3).(나)
|
||||||
|
* 「성토사면 길이 5m 초과 시 옹벽·석축」. 실무 표본(오솔길 W열 성토면 거리)에서도 흔해
|
||||||
|
* (영월 63% · 봉화 49%) **경고까지만** — 구조물을 자동으로 세우지 않는다(설계자 판단).
|
||||||
|
*
|
||||||
|
* 벽이 선 쪽은 뺀다 — 기슭막이·옹벽이 사면을 끊은 쪽은 이미 조치된 자리다. **좌·우를 갈라**
|
||||||
|
* 한쪽에만 벽이 서면 반대쪽은 그대로 경고한다.
|
||||||
|
* 길이는 `fillSlopeLengths`(카드 머리 「성토사면」 칸과 같은 값)를 받아 쓴다 — 여기서 다시 재지 않는다.
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import type { CrossSection } from "./B06_Section_Api_Fetch";
|
||||||
|
import { FILL_SLOPE_MAX_LENGTH_M } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||||
|
|
||||||
|
/** 브레인 승인 문구 그대로(2026-09-14) — 고치면 승인을 다시 받을 것. */
|
||||||
|
export const FILL_SLOPE_WARN_TEXT =
|
||||||
|
"성토사면 길이 5m 초과 — 법령상 옹벽·석축 설치 대상 (산림자원법 시행규칙 별표2 Ⅰ.2.차.(3).(나) · 임도설치 규정 별표7 2.차.(3).(나)) ※ 실무 표본에서도 흔함(영월 63% · 봉화 49%) — 설치 여부는 설계자 판단";
|
||||||
|
|
||||||
|
export type FillSlopeSideName = "left" | "right";
|
||||||
|
|
||||||
|
export interface FillSlopeSideLength {
|
||||||
|
lengthM: number;
|
||||||
|
/** 원지반을 못 만나 거기까지만 잰 하한값. */
|
||||||
|
open: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FillSlopeWarning {
|
||||||
|
section: CrossSection;
|
||||||
|
sides: Array<{ side: FillSlopeSideName } & FillSlopeSideLength>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 벽이 서서 성토사면을 끊는 쪽 — 배관 기슭막이 · 독립 기슭막이 · 세월교·BOX암거 측벽. */
|
||||||
|
export function wallSides(section: CrossSection): Set<FillSlopeSideName> {
|
||||||
|
const sides = new Set<FillSlopeSideName>();
|
||||||
|
if (section.ford || section.box) return new Set(["left", "right"]);
|
||||||
|
const culvert = section.culvert;
|
||||||
|
if (culvert?.hidden_pipe) {
|
||||||
|
// 독립 기슭막이 설치 측 — 좌 = +offset(`restrictToSide`) · 양쪽·미지정은 둘 다.
|
||||||
|
if (culvert.side !== "우") sides.add("left");
|
||||||
|
if (culvert.side !== "좌") sides.add("right");
|
||||||
|
} else if (culvert) {
|
||||||
|
// 유입 = 상단측(미상이면 좌) · 집수정은 벽이 아니다.
|
||||||
|
const inlet: FillSlopeSideName = (section.uphill_side ?? "left") === "left" ? "left" : "right";
|
||||||
|
if (culvert.inlet.structure !== "집수정") sides.add(inlet);
|
||||||
|
if (culvert.outlet.structure !== "집수정") sides.add(inlet === "left" ? "right" : "left");
|
||||||
|
}
|
||||||
|
const revetment = section.revetment;
|
||||||
|
if (revetment) {
|
||||||
|
// 설치 측이 비면 성토가 나는 쪽(`computeRevetmentLayout` 과 같은 규칙).
|
||||||
|
const mode = section.design?.section_mode;
|
||||||
|
const side =
|
||||||
|
revetment.side === "우"
|
||||||
|
? "right"
|
||||||
|
: revetment.side === "좌"
|
||||||
|
? "left"
|
||||||
|
: mode === "left_cut"
|
||||||
|
? "right"
|
||||||
|
: mode === "right_cut" || mode === "both_fill"
|
||||||
|
? "left"
|
||||||
|
: null;
|
||||||
|
if (side) sides.add(side);
|
||||||
|
}
|
||||||
|
return sides;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 5m 를 **넘는** 성토사면(벽 선 쪽 뺌)이 있는 측점만 — 측점 순서 그대로. */
|
||||||
|
export function fillSlopeWarnings(
|
||||||
|
sections: ReadonlyArray<CrossSection>,
|
||||||
|
lengthsOf: (section: CrossSection) => Record<FillSlopeSideName, FillSlopeSideLength | null>,
|
||||||
|
): FillSlopeWarning[] {
|
||||||
|
const warnings: FillSlopeWarning[] = [];
|
||||||
|
for (const section of sections) {
|
||||||
|
const lengths = lengthsOf(section);
|
||||||
|
const walls = wallSides(section);
|
||||||
|
const sides = (["left", "right"] as const)
|
||||||
|
.filter((side) => !walls.has(side))
|
||||||
|
.flatMap((side) => {
|
||||||
|
const length = lengths[side];
|
||||||
|
return length && length.lengthM > FILL_SLOPE_MAX_LENGTH_M + 1e-6
|
||||||
|
? [{ side, ...length }]
|
||||||
|
: [];
|
||||||
|
});
|
||||||
|
if (sides.length) warnings.push({ section, sides });
|
||||||
|
}
|
||||||
|
return warnings;
|
||||||
|
}
|
||||||
@@ -379,7 +379,7 @@ export function createBoxControls(deps: FordControlDeps): {
|
|||||||
// 캐시 먼저 — 구체 길이(`span_m`)는 백엔드 `_box_set`과 같은 식으로 다시 잡는다.
|
// 캐시 먼저 — 구체 길이(`span_m`)는 백엔드 `_box_set`과 같은 식으로 다시 잡는다.
|
||||||
if (patch.body_width_m) {
|
if (patch.body_width_m) {
|
||||||
spec.inner_width_m = patch.body_width_m;
|
spec.inner_width_m = patch.body_width_m;
|
||||||
spec.span_m = boxSpanM(patch.body_width_m);
|
spec.span_m = boxSpanM(patch.body_width_m, spec.wall_thickness_m);
|
||||||
}
|
}
|
||||||
if (patch.body_height_m) spec.inner_height_m = patch.body_height_m;
|
if (patch.body_height_m) spec.inner_height_m = patch.body_height_m;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
|
|||||||
import type {
|
import type {
|
||||||
CrossSection,
|
CrossSection,
|
||||||
EarthworkConversion,
|
EarthworkConversion,
|
||||||
HaulEquipmentLimit,
|
|
||||||
SectionDetailResponse,
|
SectionDetailResponse,
|
||||||
} from "./B06_Section_Api_Fetch";
|
} from "./B06_Section_Api_Fetch";
|
||||||
import type { RockBoundaryControl } from "./B06_Section_UI_Cross_Design";
|
import type { RockBoundaryControl } from "./B06_Section_UI_Cross_Design";
|
||||||
@@ -81,53 +80,10 @@ import {
|
|||||||
inferStationInterval,
|
inferStationInterval,
|
||||||
L,
|
L,
|
||||||
} from "./B06_Section_UI_Section_Common";
|
} from "./B06_Section_UI_Section_Common";
|
||||||
|
import type { SectionViewController } from "./B06_Section_UI_Section_View_Types";
|
||||||
|
import { createFillSlopeNotice } from "./B06_Section_UI_Cross_FillSlope_Notice";
|
||||||
|
|
||||||
export type { CrossDesignChange, DesignChangeHandler, RockBoundaryControl };
|
export type { CrossDesignChange, DesignChangeHandler, RockBoundaryControl, SectionViewController };
|
||||||
|
|
||||||
/**
|
|
||||||
* 상태 행·요약줄·하단 접기 손잡이·테두리가 먹는 세로 공간의 **어림값**.
|
|
||||||
*
|
|
||||||
* 평소에는 쓰지 않는다 — 그래프 몫은 `chartWrap`을 직접 재서 정한다(어림값이 실제보다 크면
|
|
||||||
* 유토곡선 아래에 빈 공간이 남는다). 화면에 붙기 전이라 잴 수 없는 첫 렌더에서만 쓰는
|
|
||||||
* 출발값이고, 최소 패널 높이 계산의 기준이기도 하다.
|
|
||||||
*/
|
|
||||||
export interface SectionViewController {
|
|
||||||
root: HTMLElement;
|
|
||||||
render: (
|
|
||||||
detail: SectionDetailResponse,
|
|
||||||
verticalExaggeration: number,
|
|
||||||
crossHalfWidth?: number,
|
|
||||||
stationInterval?: number,
|
|
||||||
earthworkConversion?: EarthworkConversion,
|
|
||||||
haulEquipmentLimits?: HaulEquipmentLimit[],
|
|
||||||
/** balloon 위치 캐시를 가르는 키(프로젝트+경로). 노선이 다르면 위치가 섞이면 안 된다. */
|
|
||||||
balloonScope?: string,
|
|
||||||
/** 자연방토 판정 경사(config). 못 받으면 자연방토 없음으로 본다. */
|
|
||||||
naturalSpoilMinSlope?: number,
|
|
||||||
) => void;
|
|
||||||
/** 측점 하나의 카드만 새로 만들어 교체한다 (전체 재렌더 없이 설계 변경 반영). */
|
|
||||||
refreshCard: (chainageM: number) => void;
|
|
||||||
/** 여러 측점을 한꺼번에 교체한다 — 상단 패널은 **마지막에 한 번만** 다시 그린다. */
|
|
||||||
refreshCards: (chainages: ReadonlyArray<number>) => void;
|
|
||||||
/** 좌측 구조물 목록에서 고른 측점 카드를 선택하고 화면에 드러낸다 — 재클릭 토글 없음
|
|
||||||
* (2026-08-29 B05/B06 일원화: 목록 클릭 → 해당 카드 스크롤·강조). */
|
|
||||||
focusStation: (stationId: string) => void;
|
|
||||||
/** 카드(측점) 선택이 바뀔 때 알림 — 좌측 「구조물 배치」 폼이 그 측점 구조물을
|
|
||||||
* 올린다(2026-08-29 일원화). null = 선택 해제. */
|
|
||||||
setStationSelectListener: (listener: (stationId: string | null) => void) => void;
|
|
||||||
clear: () => void;
|
|
||||||
/** 종단 아래 구조물 알약 레인에 쓸 목록 — B05 와 같은 부품·같은 표기(2026-09-07 사용자
|
|
||||||
* 지시 4 「구조물 표시 통일」). 좌측 「구조물 배치」가 목록을 받을 때마다 넘겨준다. */
|
|
||||||
setStructureMarks: (
|
|
||||||
structures: ReadonlyArray<StructureInstance>,
|
|
||||||
types: ReadonlyArray<StructureType>,
|
|
||||||
) => void;
|
|
||||||
/** 종단 그래프 우클릭으로 구조물을 넣고 빼는 길(2026-09-12 B05·B06 일원화). */
|
|
||||||
setStructureEdit: (edit: SectionStructureEdit | null) => void;
|
|
||||||
/** 계획선 편집 ▲/▼ — 그릴 때마다 불러 선형·편집 함수를 받는다(null = 버튼 없음). */
|
|
||||||
setGradeEdit: (provider: (() => LongitudinalPanelInput["grade"]) | null) => void;
|
|
||||||
dispose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createSectionView(
|
export function createSectionView(
|
||||||
onDesignChange?: DesignChangeHandler,
|
onDesignChange?: DesignChangeHandler,
|
||||||
@@ -236,6 +192,10 @@ export function createSectionView(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
panel.append(panelResizer.root);
|
panel.append(panelResizer.root);
|
||||||
|
// 성토사면 5m 초과 경고 줄(2026-09-14 브레인 (나)) — 패널과 카드 사이 · 측점 누르면 그 카드로.
|
||||||
|
const fillSlopeNotice = createFillSlopeNotice((id) =>
|
||||||
|
selectedStationId === id ? revealCard(id, "smooth") : selectStation(id, true),
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 패널 **어디에서든** 굴린 휠은 페이지가 아니라 그래프를 좌우로 민다(2026-08-02 사용자 지시).
|
* 패널 **어디에서든** 굴린 휠은 페이지가 아니라 그래프를 좌우로 민다(2026-08-02 사용자 지시).
|
||||||
@@ -489,6 +449,7 @@ export function createSectionView(
|
|||||||
// 보던 자리가 맨 앞으로 튀면 못 쓴다. 위치를 잡아 뒀다 되돌린다.
|
// 보던 자리가 맨 앞으로 튀면 못 쓴다. 위치를 잡아 뒀다 되돌린다.
|
||||||
const keepScrollLeft = chartWrap.scrollLeft;
|
const keepScrollLeft = chartWrap.scrollLeft;
|
||||||
const detail = currentDetail;
|
const detail = currentDetail;
|
||||||
|
fillSlopeNotice.update(detail.cross_sections, cachedStationInterval); // 카드 갱신도 여기를 거침
|
||||||
// 그래프 몫은 **추정하지 않고 잰다**. `chartWrap`은 `flex: 1 / min-height: 0`이라 높이가
|
// 그래프 몫은 **추정하지 않고 잰다**. `chartWrap`은 `flex: 1 / min-height: 0`이라 높이가
|
||||||
// 내용이 아니라 패널에서 정해지므로, 재서 쓰면 되먹임 없이 한 번에 수렴한다.
|
// 내용이 아니라 패널에서 정해지므로, 재서 쓰면 되먹임 없이 한 번에 수렴한다.
|
||||||
// 화면에 붙기 전(detached)에는 잴 수 없으니 그때만 `PANEL_CHROME_PX` 추정치로 시작한다.
|
// 화면에 붙기 전(detached)에는 잴 수 없으니 그때만 `PANEL_CHROME_PX` 추정치로 시작한다.
|
||||||
@@ -599,7 +560,7 @@ export function createSectionView(
|
|||||||
}
|
}
|
||||||
// panel은 이미 root의 자식이라 replaceChildren이 떼었다 붙이면서 스크롤을 잃는다.
|
// panel은 이미 root의 자식이라 replaceChildren이 떼었다 붙이면서 스크롤을 잃는다.
|
||||||
const keepScrollLeft = chartWrap.scrollLeft;
|
const keepScrollLeft = chartWrap.scrollLeft;
|
||||||
root.replaceChildren(panel, grid);
|
root.replaceChildren(panel, fillSlopeNotice.root, grid);
|
||||||
chartWrap.scrollLeft = keepScrollLeft;
|
chartWrap.scrollLeft = keepScrollLeft;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* B06_Section_UI_Section_View_Types.ts
|
||||||
|
* `createSectionView` 가 돌려주는 조종기 모양 — 700줄 제한으로 뷰 본체에서 떼어 냄(2026-09-14).
|
||||||
|
* 부르는 쪽은 종전대로 `_UI_Section_View` 에서 가져간다(거기서 다시 내보냄).
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import type {
|
||||||
|
EarthworkConversion,
|
||||||
|
HaulEquipmentLimit,
|
||||||
|
SectionDetailResponse,
|
||||||
|
} from "./B06_Section_Api_Fetch";
|
||||||
|
import type { SectionStructureEdit } from "./B06_Section_UI_Section_View_Menu";
|
||||||
|
import type { LongitudinalPanelInput } from "./B06_Section_UI_Section_View_Draw";
|
||||||
|
import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상태 행·요약줄·하단 접기 손잡이·테두리가 먹는 세로 공간의 **어림값**.
|
||||||
|
*
|
||||||
|
* 평소에는 쓰지 않는다 — 그래프 몫은 `chartWrap`을 직접 재서 정한다(어림값이 실제보다 크면
|
||||||
|
* 유토곡선 아래에 빈 공간이 남는다). 화면에 붙기 전이라 잴 수 없는 첫 렌더에서만 쓰는
|
||||||
|
* 출발값이고, 최소 패널 높이 계산의 기준이기도 하다.
|
||||||
|
*/
|
||||||
|
export interface SectionViewController {
|
||||||
|
root: HTMLElement;
|
||||||
|
render: (
|
||||||
|
detail: SectionDetailResponse,
|
||||||
|
verticalExaggeration: number,
|
||||||
|
crossHalfWidth?: number,
|
||||||
|
stationInterval?: number,
|
||||||
|
earthworkConversion?: EarthworkConversion,
|
||||||
|
haulEquipmentLimits?: HaulEquipmentLimit[],
|
||||||
|
/** balloon 위치 캐시를 가르는 키(프로젝트+경로). 노선이 다르면 위치가 섞이면 안 된다. */
|
||||||
|
balloonScope?: string,
|
||||||
|
/** 자연방토 판정 경사(config). 못 받으면 자연방토 없음으로 본다. */
|
||||||
|
naturalSpoilMinSlope?: number,
|
||||||
|
) => void;
|
||||||
|
/** 측점 하나의 카드만 새로 만들어 교체한다 (전체 재렌더 없이 설계 변경 반영). */
|
||||||
|
refreshCard: (chainageM: number) => void;
|
||||||
|
/** 여러 측점을 한꺼번에 교체한다 — 상단 패널은 **마지막에 한 번만** 다시 그린다. */
|
||||||
|
refreshCards: (chainages: ReadonlyArray<number>) => void;
|
||||||
|
/** 좌측 구조물 목록에서 고른 측점 카드를 선택하고 화면에 드러낸다 — 재클릭 토글 없음
|
||||||
|
* (2026-08-29 B05/B06 일원화: 목록 클릭 → 해당 카드 스크롤·강조). */
|
||||||
|
focusStation: (stationId: string) => void;
|
||||||
|
/** 카드(측점) 선택이 바뀔 때 알림 — 좌측 「구조물 배치」 폼이 그 측점 구조물을
|
||||||
|
* 올린다(2026-08-29 일원화). null = 선택 해제. */
|
||||||
|
setStationSelectListener: (listener: (stationId: string | null) => void) => void;
|
||||||
|
clear: () => void;
|
||||||
|
/** 종단 아래 구조물 알약 레인에 쓸 목록 — B05 와 같은 부품·같은 표기(2026-09-07 사용자
|
||||||
|
* 지시 4 「구조물 표시 통일」). 좌측 「구조물 배치」가 목록을 받을 때마다 넘겨준다. */
|
||||||
|
setStructureMarks: (
|
||||||
|
structures: ReadonlyArray<StructureInstance>,
|
||||||
|
types: ReadonlyArray<StructureType>,
|
||||||
|
) => void;
|
||||||
|
/** 종단 그래프 우클릭으로 구조물을 넣고 빼는 길(2026-09-12 B05·B06 일원화). */
|
||||||
|
setStructureEdit: (edit: SectionStructureEdit | null) => void;
|
||||||
|
/** 계획선 편집 ▲/▼ — 그릴 때마다 불러 선형·편집 함수를 받는다(null = 버튼 없음). */
|
||||||
|
setGradeEdit: (provider: (() => LongitudinalPanelInput["grade"]) | null) => void;
|
||||||
|
dispose: () => void;
|
||||||
|
}
|
||||||
@@ -195,6 +195,29 @@
|
|||||||
gap: var(--spacing-16);
|
gap: var(--spacing-16);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 성토사면 5m 초과 경고 줄(2026-09-14) — 요약 한 줄, 펼치면 측점 단추 목록. */
|
||||||
|
.b06-section__notice {
|
||||||
|
margin-bottom: var(--spacing-8);
|
||||||
|
color: var(--color-warning);
|
||||||
|
font-size: var(--text-caption);
|
||||||
|
}
|
||||||
|
|
||||||
|
.b06-section__notice > summary {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.b06-section__notice-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--spacing-4);
|
||||||
|
padding-top: var(--spacing-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.b06-section__notice-list > button {
|
||||||
|
font-size: var(--text-caption);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.b06-cross-card {
|
.b06-cross-card {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition:
|
transition:
|
||||||
|
|||||||
@@ -520,7 +520,9 @@ def build_mass_haul_drawing(
|
|||||||
if curve_entity:
|
if curve_entity:
|
||||||
entities.append(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):
|
if isinstance(plan, dict):
|
||||||
entities.extend(_band_entities(drawing_id, plan, curve, mm_h))
|
entities.extend(_band_entities(drawing_id, plan, curve, mm_h))
|
||||||
entities.extend(_residual_entities(drawing_id, plan, curve, interval_m, mm_h))
|
entities.extend(_residual_entities(drawing_id, plan, curve, interval_m, mm_h))
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support import (
|
|||||||
_invalidate_drawing,
|
_invalidate_drawing,
|
||||||
_read_drawing,
|
_read_drawing,
|
||||||
_read_json,
|
_read_json,
|
||||||
_recompute_confirmed_design,
|
|
||||||
_store_confirmed_drawing,
|
_store_confirmed_drawing,
|
||||||
landuse_source,
|
landuse_source,
|
||||||
lidar_source,
|
lidar_source,
|
||||||
@@ -405,7 +404,7 @@ async def confirm_design_drawing(
|
|||||||
) -> DesignDrawingConfirmResponse | JSONResponse:
|
) -> DesignDrawingConfirmResponse | JSONResponse:
|
||||||
"""현재 편집 도면을 영구 저장하고 도면별 확정 상태를 기록한다.
|
"""현재 편집 도면을 영구 저장하고 도면별 확정 상태를 기록한다.
|
||||||
|
|
||||||
횡단도 확정 시 B06 지정 잠정치를 동일 엔진으로 재계산해 확정치로 승격·저장한다.
|
횡단도 확정 시 담긴 측점 설계의 **상태만** 확정으로 올린다(값은 B06 정본 그대로).
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
route_id, project_root, longitudinal_path, bypass = await _confirmed_source(project_id)
|
route_id, project_root, longitudinal_path, bypass = await _confirmed_source(project_id)
|
||||||
@@ -444,9 +443,10 @@ async def confirm_design_drawing(
|
|||||||
quantity_tables or None,
|
quantity_tables or None,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 횡단도면이면 확정 단면적을 재계산한다 (재계산 실패는 도면 확정을 막지 않음).
|
# 횡단도면이면 담긴 측점 설계를 확정으로 올린다 — **상태만** 바꾼다. 장은 담긴 측점 전부.
|
||||||
# 장은 담긴 측점 전부를 함께 확정한다.
|
# B07 CAD 에는 설계를 고치는 자리가 없어 덮을 값이 없다. 예전에는 입력 셋(지반·단면·측구
|
||||||
recomputed: list[tuple[int, dict[str, Any]]] = []
|
# 쪽)만으로 단면적을 다시 계산해 설계를 통째로 덮어, 암선·절토경사·표준 횡단·구조물
|
||||||
|
# 트림과 사용자 입력(측구 끔)이 사라졌다(2026-09-14 936be972 실측 62측점 · 브레인 ②).
|
||||||
cross_match = _CROSS_ID.fullmatch(drawing_id)
|
cross_match = _CROSS_ID.fullmatch(drawing_id)
|
||||||
pool = get_db_pool()
|
pool = get_db_pool()
|
||||||
targets: list[int] = []
|
targets: list[int] = []
|
||||||
@@ -454,39 +454,21 @@ async def confirm_design_drawing(
|
|||||||
targets = list(sheet["chainages"])
|
targets = list(sheet["chainages"])
|
||||||
elif item.kind == "cross" and cross_match:
|
elif item.kind == "cross" and cross_match:
|
||||||
targets = [int(cross_match.group(1))]
|
targets = [int(cross_match.group(1))]
|
||||||
for chainage_int in targets:
|
confirmed_designs = [
|
||||||
designation = designs.get(chainage_int)
|
(chainage_int, {**designs[chainage_int], "status": "confirmed"})
|
||||||
if not designation:
|
for chainage_int in targets
|
||||||
continue
|
if designs.get(chainage_int)
|
||||||
try:
|
]
|
||||||
recomputed.append(
|
|
||||||
(
|
|
||||||
chainage_int,
|
|
||||||
await asyncio.to_thread(
|
|
||||||
_recompute_confirmed_design,
|
|
||||||
longitudinal_path,
|
|
||||||
f"cross_{chainage_int:05d}m",
|
|
||||||
designation,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except (ValueError, KeyError, FileNotFoundError, OSError):
|
|
||||||
logger.warning(
|
|
||||||
"B07 확정 단면적 재계산 실패 (도면 확정은 유지): drawing_id=%s 측점=%s",
|
|
||||||
drawing_id,
|
|
||||||
chainage_int,
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
async with pool.acquire() as connection:
|
async with pool.acquire() as connection:
|
||||||
await connection.begin()
|
await connection.begin()
|
||||||
try:
|
try:
|
||||||
for chainage_int, confirmed_design in recomputed:
|
for chainage_int, _design in confirmed_designs:
|
||||||
await merge_cross_section_design_by_round(
|
await merge_cross_section_design_by_round(
|
||||||
connection,
|
connection,
|
||||||
route_id=route_id,
|
route_id=route_id,
|
||||||
chainage_int=chainage_int,
|
chainage_int=chainage_int,
|
||||||
patch=confirmed_design,
|
patch={"status": "confirmed"},
|
||||||
)
|
)
|
||||||
async with connection.cursor() as cursor:
|
async with connection.cursor() as cursor:
|
||||||
if all_confirmed:
|
if all_confirmed:
|
||||||
@@ -502,7 +484,7 @@ async def confirm_design_drawing(
|
|||||||
id=drawing_id,
|
id=drawing_id,
|
||||||
confirmed=True,
|
confirmed=True,
|
||||||
all_confirmed=all_confirmed,
|
all_confirmed=all_confirmed,
|
||||||
design=recomputed[0][1] if len(recomputed) == 1 else None,
|
design=confirmed_designs[0][1] if len(confirmed_designs) == 1 else None,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
||||||
|
|||||||
@@ -646,32 +646,3 @@ def _invalidate_drawing(project_root: Path, drawing_id: str) -> None:
|
|||||||
if entry:
|
if entry:
|
||||||
entry["confirmed"] = False
|
entry["confirmed"] = False
|
||||||
_write_manifest(project_root, manifest)
|
_write_manifest(project_root, manifest)
|
||||||
|
|
||||||
|
|
||||||
def _recompute_confirmed_design(
|
|
||||||
longitudinal_path: Path, cross_stem: str, designation: dict[str, Any]
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""B06 지정값과 현재 계획고로 절·성토 단면적을 재계산해 확정치(status=confirmed)로 만든다.
|
|
||||||
|
|
||||||
B07 CAD에는 아직 편집 가능한 설계선이 없으므로, 저장된 지정값(지반유형·단면유형·
|
|
||||||
측구위치)과 계획고로 동일 엔진을 재실행해 확정 시점 값을 고정한다.
|
|
||||||
"""
|
|
||||||
longitudinal = _read_json(longitudinal_path)
|
|
||||||
cross_path = longitudinal_path.parent.parent / "cross_sections" / f"{cross_stem}.json"
|
|
||||||
source = _read_json(cross_path)
|
|
||||||
samples = source.get("samples")
|
|
||||||
if not isinstance(samples, list):
|
|
||||||
raise ValueError("횡단 상세 파일 형식이 올바르지 않습니다.")
|
|
||||||
design_elevation = design_elevation_from_longitudinal(
|
|
||||||
longitudinal, float(source.get("chainage_m", 0.0))
|
|
||||||
)
|
|
||||||
design = compute_cross_design(
|
|
||||||
samples,
|
|
||||||
design_elevation,
|
|
||||||
ground_type=designation["ground_type"],
|
|
||||||
section_mode=designation["section_mode"],
|
|
||||||
ditch_side=designation.get("ditch_side"),
|
|
||||||
**curve_widening_args(source),
|
|
||||||
)
|
|
||||||
design["status"] = "confirmed"
|
|
||||||
return design
|
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ class DesignDrawingConfirmResponse(BaseModel):
|
|||||||
id: str
|
id: str
|
||||||
confirmed: bool
|
confirmed: bool
|
||||||
all_confirmed: bool
|
all_confirmed: bool
|
||||||
# 횡단도 확정 시 재계산된 확정 설계(status=confirmed). 종단도·재계산 불가 시 None.
|
# 횡단도 확정 시 저장된 설계(상태만 status=confirmed). 종단도·장·설계 없음이면 None.
|
||||||
design: dict[str, Any] | None = None
|
design: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -418,7 +418,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
|||||||
// **기다린다**: 안 기다리면 오버레이가 먼저 걷혀, 버튼은 [수정]인데 CAD는 아직
|
// **기다린다**: 안 기다리면 오버레이가 먼저 걷혀, 버튼은 [수정]인데 CAD는 아직
|
||||||
// 편집이 열린 어긋난 순간이 생긴다.
|
// 편집이 열린 어긋난 순간이 생긴다.
|
||||||
await loadDrawing(currentDrawing, currentIndex);
|
await loadDrawing(currentDrawing, currentIndex);
|
||||||
// 확정 시 재계산된 확정 단면적으로 지반/계획 정보 패널을 갱신한다.
|
// 확정한 설계(B06 정본 그대로 · 상태만 확정)로 지반/계획 정보 패널을 갱신한다.
|
||||||
if (currentDrawing.kind === "cross") {
|
if (currentDrawing.kind === "cross") {
|
||||||
infoPanelHost.replaceChildren(
|
infoPanelHost.replaceChildren(
|
||||||
buildDesignInfoPanel(
|
buildDesignInfoPanel(
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ function infoRow(label: string, value: string): HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 선택 횡단도의 지반정보/계획정보를 2분할로 렌더한다 (잠정치, B07 확정 시 재계산).
|
* 선택 횡단도의 지반정보/계획정보를 2분할로 렌더한다 (B06 정본 값 · B07 확정은 상태만 올림).
|
||||||
*
|
*
|
||||||
* **장(여러 측점을 담은 도면)에는 측점 단위 값이 없다** — 서버가 `design` 을 넘기지
|
* **장(여러 측점을 담은 도면)에는 측점 단위 값이 없다** — 서버가 `design` 을 넘기지
|
||||||
* 않는데도 제목만 「측점 …」으로 달려 어느 측점 값인지 오해됐다(2026-09-03 정리).
|
* 않는데도 제목만 「측점 …」으로 달려 어느 측점 값인지 오해됐다(2026-09-03 정리).
|
||||||
|
|||||||
@@ -648,8 +648,10 @@ def build() -> dict[str, Any]:
|
|||||||
basis_found += 1
|
basis_found += 1
|
||||||
if basis_quantity_is_grouped(basis_qty):
|
if basis_quantity_is_grouped(basis_qty):
|
||||||
basis_grouped += 1
|
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(
|
basis_missing.append(
|
||||||
{
|
{
|
||||||
"pum_table_id": table["table_id"],
|
"pum_table_id": table["table_id"],
|
||||||
|
|||||||
@@ -110,6 +110,11 @@ FORM_JUDGMENTS: dict[str, tuple[str, str, str]] = {
|
|||||||
# 절 제목으로 읽어 12-2 에 붙어 있던 표(빌더가 앞 절 이어받기로 고침). 형태 판정은 그대로.
|
# 절 제목으로 읽어 12-2 에 붙어 있던 표(빌더가 앞 절 이어받기로 고침). 형태 판정은 그대로.
|
||||||
"F0358": ("12-17-1", "reference", "시설유형 Type-Ⅰ~Ⅳ 적용 기준 설명"),
|
"F0358": ("12-17-1", "reference", "시설유형 Type-Ⅰ~Ⅳ 적용 기준 설명"),
|
||||||
"F0360": ("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」 항목만 — 값이 없는 구성 안내"),
|
"F0388": ("12-34-4", "reference", "「재료비 JOINT FILLER」 항목만 — 값이 없는 구성 안내"),
|
||||||
"F0390": ("12-36", "reference", "제작비·운송비·설치비 「견적처리」"),
|
"F0390": ("12-36", "reference", "제작비·운송비·설치비 「견적처리」"),
|
||||||
"F0392": ("12-38-1", "coefficient", "사용횟수별 잔존율(12회 25%·25회 10%)"),
|
"F0392": ("12-38-1", "coefficient", "사용횟수별 잔존율(12회 25%·25회 10%)"),
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
⚠ 못 가른 표는 **빈칸** — 자원을 못 맞춘 표 · 계수 · 참조 · 미판정 표에 갈래를 지어내지 않는다.
|
⚠ 못 가른 표는 **빈칸** — 자원을 못 맞춘 표 · 계수 · 참조 · 미판정 표에 갈래를 지어내지 않는다.
|
||||||
|
|
||||||
공종마다 `variant_keys` — 표들의 갈래 + 원문이 정했으나 자원 줄로 안 서는 갈래
|
공종마다 `variant_keys` — 표들의 갈래 + 원문이 정했으나 자원 줄로 안 서는 갈래
|
||||||
(불도저 운반 공식 갈래 · 합산형 단계 잎의 암질 행 · 9-4-1 [주]① 평균) · 합산형 부모는 단계 갈래.
|
(불도저 운반 공식 갈래 · 합산형 단계 잎의 암질 행 · 9-4-1 [주]① 평균 · 유로폼 12-38-2 부자재 요율 머리) · 합산형 부모는 단계 갈래.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -28,6 +28,9 @@ def _add(labels: list[str], seen: set[str], label: str, normalize: Any) -> None:
|
|||||||
|
|
||||||
def attach_variant_keys(nodes: list[dict[str, Any]], edition: str) -> None:
|
def attach_variant_keys(nodes: list[dict[str, Any]], edition: str) -> None:
|
||||||
"""표마다 `variant_key`, 공종마다 `variant_keys` 를 채운다(자리에서)."""
|
"""표마다 `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_MachineProductivity_Dozer import extract_dozer_factors
|
||||||
from B09_Estimation.B09_Estimation_ParentSteps import AVERAGE_BASIS, AVERAGE_VARIANT, rock_rows
|
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 import build_resource_axis
|
||||||
@@ -62,6 +65,9 @@ def attach_variant_keys(nodes: list[dict[str, Any]], edition: str) -> None:
|
|||||||
if code in steps:
|
if code in steps:
|
||||||
for rock, _, _ in rock_rows({"tables": [table]}):
|
for rock, _, _ in rock_rows({"tables": [table]}):
|
||||||
_add(labels, table_seen, rock, normalize)
|
_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
|
table["variant_key"] = labels
|
||||||
for label in labels:
|
for label in labels:
|
||||||
_add(keys, seen, label, normalize)
|
_add(keys, seen, label, normalize)
|
||||||
|
|||||||
@@ -52,6 +52,17 @@ FACE_DRESSING_FILL_SUGGESTED = (
|
|||||||
"영월 실무 설계내역 「성토사면고르기 06M3 B/H」(백호 = 무한궤도 굴착기) · 임도는 산지라"
|
"영월 실무 설계내역 「성토사면고르기 06M3 B/H」(백호 = 무한궤도 굴착기) · 임도는 산지라"
|
||||||
" 타이어식이 잘 안 들어감 — 타이어식도 고를 수 있음(2026-09-14 브레인 ②)",
|
" 타이어식이 잘 안 들어감 — 타이어식도 고를 수 있음(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 브레인 판정 Ⓐ — 밑수 = 초류종자살포(파종) 면적. 반영률 칸을 따로 두면 같은 값을
|
#: ⭐ 2026-09-14 브레인 판정 Ⓐ — 밑수 = 초류종자살포(파종) 면적. 반영률 칸을 따로 두면 같은 값을
|
||||||
#: 두 곳에서 관리하게 됨. 실무도 그렇게 움직임(거창 파종 성토 50%·절토 100% · 영월 성토 50%).
|
#: 두 곳에서 관리하게 됨. 실무도 그렇게 움직임(거창 파종 성토 50%·절토 100% · 영월 성토 50%).
|
||||||
FACE_DRESSING_NOTE = (
|
FACE_DRESSING_NOTE = (
|
||||||
@@ -108,6 +119,17 @@ class SummaryInput:
|
|||||||
subgrade_compaction_enabled: bool = False
|
subgrade_compaction_enabled: bool = False
|
||||||
# 면고르기 면적 덮어쓰기 `{fill, cut}`(㎡) — 비우면 파종 면적(판정 Ⓐ).
|
# 면고르기 면적 덮어쓰기 `{fill, cut}`(㎡) — 비우면 파종 면적(판정 Ⓐ).
|
||||||
face_dressing_area_m2: dict[str, float | None] = field(default_factory=dict)
|
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 판정 「별도 줄 · 칸으로 켜고 끔 · 기본 꺼짐」.
|
#: 노체다짐 줄 — ⭐ 2026-09-13 판정 「별도 줄 · 칸으로 켜고 끔 · 기본 꺼짐」.
|
||||||
@@ -180,6 +202,16 @@ def build_rows(source: SummaryInput) -> list[SummaryRow]:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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))
|
rows.extend(_haul_rows(source))
|
||||||
|
|
||||||
@@ -365,7 +397,7 @@ def _haul_rows(source: SummaryInput) -> list[SummaryRow]:
|
|||||||
for item in source.haul_rows:
|
for item in source.haul_rows:
|
||||||
key = str(item.get("equipment") or "")
|
key = str(item.get("equipment") or "")
|
||||||
label = HAUL_LABELS.get(key, key 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")
|
distance = item.get("average_distance_m")
|
||||||
note = f"평균운반거리 {float(distance):.2f} m" if isinstance(distance, (int, float)) else ""
|
note = f"평균운반거리 {float(distance):.2f} m" if isinstance(distance, (int, float)) else ""
|
||||||
# ⚠ **자연상태로 싣는다** — 「운반거리 산정은 다짐상태, 내역서 수량은 자연상태」
|
# ⚠ **자연상태로 싣는다** — 「운반거리 산정은 다짐상태, 내역서 수량은 자연상태」
|
||||||
|
|||||||
@@ -105,15 +105,17 @@ def annotate(
|
|||||||
component["reuse_note"] = NOTE_REUSE_MISSING
|
component["reuse_note"] = NOTE_REUSE_MISSING
|
||||||
continue
|
continue
|
||||||
count = entry.get("reuse_count")
|
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:
|
for component in targets:
|
||||||
component["reuse_count"] = count
|
component["reuse_count"] = count
|
||||||
component["reuse_note"] = (
|
component["reuse_note"] = (
|
||||||
f"품셈 1-7-1 {count}회 — 「{entry.get('matched_example')}」"
|
f"{basis} {count}회 — 「{entry.get('matched_example')}」"
|
||||||
if count
|
if count
|
||||||
else NOTE_NOT_APPLICABLE
|
else NOTE_NOT_APPLICABLE
|
||||||
)
|
)
|
||||||
if count:
|
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:
|
else:
|
||||||
missing.append(type_id)
|
missing.append(type_id)
|
||||||
return notes, sorted(set(missing))
|
return notes, sorted(set(missing))
|
||||||
|
|||||||
@@ -115,6 +115,8 @@ def build_handoff(
|
|||||||
stand_volume_class: str | None = None,
|
stand_volume_class: str | None = None,
|
||||||
face_dressing_cut_class: str | None = None,
|
face_dressing_cut_class: str | None = None,
|
||||||
face_dressing_fill_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,
|
priced_sheets: list[dict[str, Any]] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""B09 가 그대로 받는 모양. 없는 표는 건너뛰되 **빈 표와 구별해 적는다**.
|
"""B09 가 그대로 받는 모양. 없는 표는 건너뛰되 **빈 표와 구별해 적는다**.
|
||||||
@@ -134,6 +136,10 @@ def build_handoff(
|
|||||||
"stand_volume_class": stand_volume_class,
|
"stand_volume_class": stand_volume_class,
|
||||||
"face_dressing_cut_class": face_dressing_cut_class,
|
"face_dressing_cut_class": face_dressing_cut_class,
|
||||||
"face_dressing_fill_class": face_dressing_fill_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(
|
rows, misses = _earthwork_rows(
|
||||||
summary_table, table, methods, bench_cut_depth_m, variant_inputs
|
summary_table, table, methods, bench_cut_depth_m, variant_inputs
|
||||||
|
|||||||
@@ -38,11 +38,11 @@ def _pickers(structure: dict[str, Any], mapping: WorkItemMapping) -> list[tuple[
|
|||||||
"""이 구조물의 성분을 **이름으로 집는** 공종 줄 — (자리, 이름들). 집는 조건은 각 빌더와 같다."""
|
"""이 구조물의 성분을 **이름으로 집는** 공종 줄 — (자리, 이름들). 집는 조건은 각 빌더와 같다."""
|
||||||
type_id = str(structure.get("type_id") or "")
|
type_id = str(structure.get("type_id") or "")
|
||||||
entry = mapping.for_structure(type_id) or {}
|
entry = mapping.for_structure(type_id) or {}
|
||||||
composite = mapping.composite_for(type_id)
|
composite = mapping.composite_for(type_id, structure)
|
||||||
found: list[tuple[str, set[str]]] = []
|
found: list[tuple[str, set[str]]] = []
|
||||||
if entry.get("billing_component"):
|
if entry.get("billing_component"):
|
||||||
found.append(("구조물 줄", {str(entry["billing_component"])}))
|
found.append(("구조물 줄", {str(entry["billing_component"])}))
|
||||||
if composite and not entry.get("work_item_code"):
|
if composite: # 걸린 묶음은 곧장 잇는 코드보다 위(빌더와 같은 조건)
|
||||||
for part in composite.get("parts") or []:
|
for part in composite.get("parts") or []:
|
||||||
if isinstance(part, dict):
|
if isinstance(part, dict):
|
||||||
label = f"묶음 조각 「{part.get('name') or part.get('code')}」"
|
label = f"묶음 조각 「{part.get('name') or part.get('code')}」"
|
||||||
|
|||||||
@@ -146,10 +146,16 @@ BLOCKED_UNCONFIRMED = "unconfirmed"
|
|||||||
#: ⚠ `item` 칸이 **지반 갈래**인 공종 — 그 밖의 공종에서 `item` 은 **작업 갈래**다
|
#: ⚠ `item` 칸이 **지반 갈래**인 공종 — 그 밖의 공종에서 `item` 은 **작업 갈래**다
|
||||||
#: (지장목제거의 「뿌리뽑기·잡관목제거」). 갈래로 읽으면 「시공법 미지정」이라는 **틀린 사유**가
|
#: (지장목제거의 「뿌리뽑기·잡관목제거」). 갈래로 읽으면 「시공법 미지정」이라는 **틀린 사유**가
|
||||||
#: 붙는다(2026-09-09 실측). 정의처는 `EarthworkSummary` 이고 여기서 그대로 가져다 쓴다.
|
#: 붙는다(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
|
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import ( # noqa: E402
|
||||||
GROUND_SPLIT_GROUPS as GROUND_SPLIT_GROUPS,
|
GROUND_SPLIT_GROUPS as GROUND_SPLIT_GROUPS,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
#: 코드 없이 **수량만** 서는 집계 줄 — 막힘 사유는 집계 비고 그대로(토취 · 2026-09-14 브레인 ①).
|
||||||
|
SUMMARY_ONLY_GROUPS = frozenset({BORROW_NAME})
|
||||||
|
|
||||||
SLOPE_GROUPS = frozenset({"성토면다짐", "초류종자살포", "지장목제거", "층따기", "면고르기"})
|
SLOPE_GROUPS = frozenset({"성토면다짐", "초류종자살포", "지장목제거", "층따기", "면고르기"})
|
||||||
|
|
||||||
#: 집계 합계 줄 — 내역 줄이 아니라 검산용이다.
|
#: 집계 합계 줄 — 내역 줄이 아니라 검산용이다.
|
||||||
@@ -271,13 +277,20 @@ class WorkItemMapping:
|
|||||||
return row
|
return row
|
||||||
return None
|
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 []:
|
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 row
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -382,6 +395,42 @@ def composite_quantities(
|
|||||||
entry["not_ready"] = True
|
entry["not_ready"] = True
|
||||||
entry["why"] = why
|
entry["why"] = why
|
||||||
missing.append({"code": spec.get("code"), "reason": 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 == "pavement_thickness":
|
||||||
|
# 12-6 갈래는 포장두께 20·30·40㎝ — 원단위가 쓴 두께(칸 또는 B06) 그대로(2026-09-15).
|
||||||
|
thickness = (structure.get("options") or {}).get("thickness_cm")
|
||||||
|
label = f"{thickness:g}㎝" if isinstance(thickness, (int, float)) else ""
|
||||||
|
if label in ("20㎝", "30㎝", "40㎝"):
|
||||||
|
entry["kind"] = label
|
||||||
|
entry["code"] = f"{spec.get('code')}#{label}"
|
||||||
|
elif found:
|
||||||
|
why = f"12-6 표는 포장두께 20·30·40㎝ 갈래뿐 — 두께 {label or '없음'} 은 못 고름"
|
||||||
|
entry["not_ready"] = True
|
||||||
|
entry["why"] = why
|
||||||
|
missing.append({"code": spec.get("code"), "reason": why})
|
||||||
if suffix == "rebar_complexity":
|
if suffix == "rebar_complexity":
|
||||||
# 갈래는 원문이 정한다 — 화면·인계에 이름과 근거를 함께 실어 사람이 검증하게 한다.
|
# 갈래는 원문이 정한다 — 화면·인계에 이름과 근거를 함께 실어 사람이 검증하게 한다.
|
||||||
complexity, why = rebar_complexity(
|
complexity, why = rebar_complexity(
|
||||||
@@ -459,10 +508,12 @@ def rebar_complexity(
|
|||||||
continue
|
continue
|
||||||
if row.get("form") == form:
|
if row.get("form") == form:
|
||||||
if row.get("class"):
|
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 "원문 예시에 없음")
|
return None, str(row.get("why") or "원문 예시에 없음")
|
||||||
if fallback and fallback.get("class"):
|
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
|
from B08_Quantity.B08_Quantity_Wording import type_label
|
||||||
|
|
||||||
detail = f"({form})" if form else "(형식이 아직 입력되지 않음)"
|
detail = f"({form})" if form else "(형식이 아직 입력되지 않음)"
|
||||||
|
|||||||
@@ -7,9 +7,11 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import string
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from B08_Quantity.B08_Quantity_Engine_BasisUnit import normalize_unit, unit_for_code
|
from B08_Quantity.B08_Quantity_Engine_BasisUnit import normalize_unit, unit_for_code
|
||||||
|
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import HAUL_LABELS
|
||||||
from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import (
|
from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import (
|
||||||
BLOCKED_FORMULA_MISSING,
|
BLOCKED_FORMULA_MISSING,
|
||||||
BLOCKED_INPUT_MISSING,
|
BLOCKED_INPUT_MISSING,
|
||||||
@@ -27,6 +29,7 @@ from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import (
|
|||||||
ORIGIN_STRUCTURE,
|
ORIGIN_STRUCTURE,
|
||||||
SLOPE_GROUPS,
|
SLOPE_GROUPS,
|
||||||
SUBTOTAL_GROUPS,
|
SUBTOTAL_GROUPS,
|
||||||
|
SUMMARY_ONLY_GROUPS,
|
||||||
WorkItemMapping,
|
WorkItemMapping,
|
||||||
composite_quantities,
|
composite_quantities,
|
||||||
masonry_class,
|
masonry_class,
|
||||||
@@ -151,19 +154,30 @@ def _earthwork_rows(
|
|||||||
# 보는 쪽을 고쳤으면 **다는 쪽도** 빠짐없이 달아야 한다.
|
# 보는 쪽을 고쳤으면 **다는 쪽도** 빠짐없이 달아야 한다.
|
||||||
blocked_kind = mismatch[1] if mismatch else None
|
blocked_kind = mismatch[1] if mismatch else None
|
||||||
blocked_reason = mismatch[2] if mismatch else ""
|
blocked_reason = mismatch[2] if mismatch else ""
|
||||||
|
inputs = {**(variant_inputs or {}), "ground_class": ground}
|
||||||
variant_value = (
|
variant_value = (
|
||||||
(entry or {}).get("variant_value")
|
(entry or {}).get("variant_value")
|
||||||
or {
|
or inputs.get(str((entry or {}).get("variant_from") or ""))
|
||||||
**(variant_inputs or {}),
|
|
||||||
"ground_class": ground,
|
|
||||||
}.get(str((entry or {}).get("variant_from") or ""))
|
|
||||||
or None
|
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 Ⓒ).
|
# 매핑이 「이 칸이 비면 못 고름」이라 적은 갈래 — 금액 없이 입력 사유(면고르기 · 09-14 Ⓒ).
|
||||||
if code and not variant_value and (entry or {}).get("variant_missing_reason"):
|
if code and missing and (entry or {}).get("variant_missing_reason"):
|
||||||
blocked_kind = blocked_kind or BLOCKED_INPUT_MISSING
|
blocked_kind = blocked_kind or BLOCKED_INPUT_MISSING
|
||||||
blocked_reason = blocked_reason or str(entry["variant_missing_reason"])
|
blocked_reason = blocked_reason or str(entry["variant_missing_reason"])
|
||||||
if code is None and not is_subtotal:
|
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
|
label = f"{group}({ground})" if ground else group
|
||||||
unmatched.append(f"{label} — {method_note}" if method_note else label)
|
unmatched.append(f"{label} — {method_note}" if method_note else label)
|
||||||
if blocked_kind is None:
|
if blocked_kind is None:
|
||||||
@@ -230,13 +244,15 @@ def _haul_rows(
|
|||||||
in_bill = bool(row.get("in_bill", True)) and entry.get("in_bill", True)
|
in_bill = bool(row.get("in_bill", True)) and entry.get("in_bill", True)
|
||||||
if code is None and in_bill:
|
if code is None and in_bill:
|
||||||
unmatched.append(f"운반({equipment})")
|
unmatched.append(f"운반({equipment})")
|
||||||
# ⚠ 코드가 없으면 **줄에 막힘 표시를 단다**(2026-09-09) — 목록에만 실으면 줄 단위로
|
# ⚠ 코드가 없으면 **줄에 막힘 표시를 단다**(2026-09-09) — 목록에만 실으면 금액이 조용히
|
||||||
# 보는 쪽이 「멀쩡한 줄」로 읽어 금액이 조용히 빠진다(도자운반·덤프운반이 그랬다).
|
# 빠진다. ⚠ `in_bill` 이 False 인 무대 줄은 **막힌 것이 아니다** — 품에 포함이라 안 세움.
|
||||||
# ⚠ `in_bill` 이 False 인 무대 줄은 **막힌 것이 아니다** — 품에 포함이라 안 세우는 것.
|
no_code = code is None and in_bill # 암 줄 막힘은 운반표가 구성비로 가르며 단 것(㉱)
|
||||||
haul_blocked = BLOCKED_UNIT_DATA_MISSING if (code is None and in_bill) else None
|
haul_blocked = (
|
||||||
haul_blocked_reason = (
|
BLOCKED_UNIT_DATA_MISSING if no_code else in_bill and row.get("blocked_kind") or None
|
||||||
f"운반({equipment})의 품셈 공종을 아직 못 이었습니다" if haul_blocked else ""
|
|
||||||
)
|
)
|
||||||
|
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
|
# 재야 맞는다) 내역에 오르는 수량은 되돌린 값이다(`config_system_design` 5-4-3
|
||||||
# 「운반거리 산정 시 모든 수량은 다짐상태로 환산해 계산하고, **내역서에 적용하는
|
# 「운반거리 산정 시 모든 수량은 다짐상태로 환산해 계산하고, **내역서에 적용하는
|
||||||
@@ -260,8 +276,10 @@ def _haul_rows(
|
|||||||
rows.append(
|
rows.append(
|
||||||
{
|
{
|
||||||
"work_item_code": code,
|
"work_item_code": code,
|
||||||
"name": f"{equipment} 운반",
|
"name": entry.get("master_name") or HAUL_LABELS.get(equipment, f"{equipment} 운반"),
|
||||||
"spec": str(row.get("ground") or ""),
|
"spec": " · ".join(
|
||||||
|
dict.fromkeys(filter(None, (row.get("rock_class"), row.get("ground"))))
|
||||||
|
),
|
||||||
"unit": "㎥",
|
"unit": "㎥",
|
||||||
"quantity": quantity,
|
"quantity": quantity,
|
||||||
# 운반에는 반영률 개념이 없다 — 그래서 `None` 이다(0 이 아니다).
|
# 운반에는 반영률 개념이 없다 — 그래서 `None` 이다(0 이 아니다).
|
||||||
@@ -424,7 +442,11 @@ def _structure_rows(
|
|||||||
# m 수량에 곱해 **2.6배** 금액이 섰다(2026-09-08 실증). 어느 성분으로 세는지는
|
# m 수량에 곱해 **2.6배** 금액이 섰다(2026-09-08 실증). 어느 성분으로 세는지는
|
||||||
# 매핑이 말한다(`billing_component`) — 코드가 짐작하지 않는다.
|
# 매핑이 말한다(`billing_component`) — 코드가 짐작하지 않는다.
|
||||||
billing = _component_billing(structure, entry)
|
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
|
kind = structure_kind(structure) if composite else None
|
||||||
parts: list[dict[str, Any]] | None = None
|
parts: list[dict[str, Any]] | None = None
|
||||||
parts_missing: list[dict[str, Any]] = []
|
parts_missing: list[dict[str, Any]] = []
|
||||||
@@ -441,6 +463,15 @@ def _structure_rows(
|
|||||||
# 갈래 축과 **저장 제원 원본값**. 가공하지 않는다.
|
# 갈래 축과 **저장 제원 원본값**. 가공하지 않는다.
|
||||||
variant_axis = str(entry.get("variant_axis") or "") or None
|
variant_axis = str(entry.get("variant_axis") or "") or None
|
||||||
variant_value = (structure.get("options") or {}).get(variant_axis) if variant_axis else 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 = [
|
secondary_axes = [
|
||||||
{"axis": axis, "value": (structure.get("options") or {}).get(axis)}
|
{"axis": axis, "value": (structure.get("options") or {}).get(axis)}
|
||||||
for axis in entry.get("secondary_axes") or []
|
for axis in entry.get("secondary_axes") or []
|
||||||
@@ -582,7 +613,7 @@ def _placing_rows(
|
|||||||
# 2026-09-08 B09 가 「철근이 겹치나」를 물어 그 김에 드러난 자리다 —
|
# 2026-09-08 B09 가 「철근이 겹치나」를 물어 그 김에 드러난 자리다 —
|
||||||
# 철근은 안 겹치고(자재는 재료·묶음 조각은 품, 재료 0원) **타설이 겹쳤다.**
|
# 철근은 안 겹치고(자재는 재료·묶음 조각은 품, 재료 0원) **타설이 겹쳤다.**
|
||||||
# ⚠ 묶음이 아직 미확보라 지금은 값이 안 걸렸을 뿐, 묶음이 서는 날 두 번이 된다.
|
# ⚠ 묶음이 아직 미확보라 지금은 값이 안 걸렸을 뿐, 묶음이 서는 날 두 번이 된다.
|
||||||
if mapping.composite_for(str(structure.get("type_id") or "")) or structure.get(
|
if mapping.composite_for(str(structure.get("type_id") or ""), structure) or structure.get(
|
||||||
"unconfirmed"
|
"unconfirmed"
|
||||||
):
|
):
|
||||||
continue # 기본값으로 선 구조물도 — 금액에 안 듦
|
continue # 기본값으로 선 구조물도 — 금액에 안 듦
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import (
|
|||||||
from B08_Quantity.B08_Quantity_Engine_Preparation_Status import (
|
from B08_Quantity.B08_Quantity_Engine_Preparation_Status import (
|
||||||
STATUS_COUNTED_ELSEWHERE as PREP_COUNTED_ELSEWHERE,
|
STATUS_COUNTED_ELSEWHERE as PREP_COUNTED_ELSEWHERE,
|
||||||
)
|
)
|
||||||
|
from B08_Quantity.B08_Quantity_Engine_Preparation_Status import (
|
||||||
|
STATUS_NEEDS_INPUT as PREP_NEEDS_INPUT,
|
||||||
|
)
|
||||||
|
from B08_Quantity.B08_Quantity_Engine_Preparation_Status import (
|
||||||
|
STATUS_NO_WORK_ITEM as PREP_NO_WORK_ITEM,
|
||||||
|
)
|
||||||
from B08_Quantity.B08_Quantity_Engine_Preparation_Status import (
|
from B08_Quantity.B08_Quantity_Engine_Preparation_Status import (
|
||||||
STATUS_NOT_APPLICABLE as PREP_NOT_APPLICABLE,
|
STATUS_NOT_APPLICABLE as PREP_NOT_APPLICABLE,
|
||||||
)
|
)
|
||||||
@@ -105,13 +111,20 @@ def _preparation_rows(preparation_table: dict[str, Any]) -> list[dict[str, Any]]
|
|||||||
|
|
||||||
|
|
||||||
def _prep_blocked_kind(status: str) -> str | None:
|
def _prep_blocked_kind(status: str) -> str | None:
|
||||||
"""준비공 줄의 상태를 **막힌 갈래 셋** 중 하나로 옮긴다. 모르는 상태는 `None`."""
|
"""준비공 줄의 상태를 **막힌 갈래 셋** 중 하나로 옮긴다. 모르는 상태는 단가 자료 없음.
|
||||||
if status == PREP_PENDING:
|
|
||||||
|
⚠ 「근거 없음」을 `input_missing` 으로 보내면 사방 원단위처럼 **우리가 만들 줄**이 B09 에
|
||||||
|
「입력이 필요합니다」로 뜬다 — 사용자가 넣을 칸을 찾아 헤맨다(2026-09-14 브레인 ㉴).
|
||||||
|
"""
|
||||||
|
if status == PREP_NEEDS_INPUT:
|
||||||
return BLOCKED_INPUT_MISSING
|
return BLOCKED_INPUT_MISSING
|
||||||
|
if status == PREP_PENDING:
|
||||||
|
return BLOCKED_UNIT_DATA_MISSING
|
||||||
if status == PREP_COUNTED_ELSEWHERE:
|
if status == PREP_COUNTED_ELSEWHERE:
|
||||||
# 다른 표에서 이미 선 줄 — 막힌 것이 아니라 **여기서 세면 안 되는** 줄이다.
|
# 다른 표에서 이미 선 줄 — 막힌 것이 아니라 **여기서 세면 안 되는** 줄이다.
|
||||||
return None
|
return None
|
||||||
if status == PREP_NOT_APPLICABLE:
|
if status in (PREP_NOT_APPLICABLE, PREP_NO_WORK_ITEM):
|
||||||
|
# 공종 없는 줄은 자재총괄 줄로 금액이 섬(「자재 단가」 탭) — 여기서 막힘으로 세면 두 벌.
|
||||||
return None
|
return None
|
||||||
return BLOCKED_UNIT_DATA_MISSING
|
return BLOCKED_UNIT_DATA_MISSING
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import (
|
|||||||
WorkItemMapping,
|
WorkItemMapping,
|
||||||
)
|
)
|
||||||
from B08_Quantity.B08_Quantity_Engine_Handoff_Rows import LOADING_EQUIPMENT
|
from B08_Quantity.B08_Quantity_Engine_Handoff_Rows import LOADING_EQUIPMENT
|
||||||
|
from B08_Quantity.B08_Quantity_Engine_RockSplit import split_amounts
|
||||||
|
|
||||||
SPOIL_NAME = "사토 운반"
|
SPOIL_NAME = "사토 운반"
|
||||||
#: ⚠ **사토장 사면 물량은 안 센다**(2026-09-09 세 창 확인). 교본 6장 3절은 「완료 구간 비탈면을
|
#: ⚠ **사토장 사면 물량은 안 센다**(2026-09-09 세 창 확인). 교본 6장 3절은 「완료 구간 비탈면을
|
||||||
@@ -98,20 +99,28 @@ def spoil_haul_rows(
|
|||||||
unknown = float(spoil.get("ground_unknown_m3") or 0.0)
|
unknown = float(spoil.get("ground_unknown_m3") or 0.0)
|
||||||
if by_ground or unknown > 0:
|
if by_ground or unknown > 0:
|
||||||
rows: list[dict[str, Any]] = []
|
rows: list[dict[str, Any]] = []
|
||||||
for label, amount in sorted(by_ground.items()):
|
# 암은 운반표와 같은 구성비 몫으로(자리표시 리핑암을 값으로 안 씀 · ㉱ `RockSplit`).
|
||||||
leg = by_distance.get(label)
|
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
|
leg_blocked = blocked if leg is None else False
|
||||||
rows.append(
|
rows.append(
|
||||||
_spoil_row(
|
_spoil_row(
|
||||||
code,
|
code,
|
||||||
amount,
|
amount,
|
||||||
distance if leg is None else leg,
|
distance if leg is None else leg,
|
||||||
leg_blocked,
|
leg_blocked or bool(share_reason),
|
||||||
reason if leg_blocked else "",
|
share_reason or (reason if leg_blocked else ""),
|
||||||
note,
|
note,
|
||||||
ground=label,
|
ground=label,
|
||||||
extra=" · ".join(
|
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
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ def rubble_base_rows(
|
|||||||
total = 0.0
|
total = 0.0
|
||||||
bases: list[str] = []
|
bases: list[str] = []
|
||||||
for structure in unit_quantity_table.get("structures") or []:
|
for structure in unit_quantity_table.get("structures") or []:
|
||||||
if mapping.composite_for(str(structure.get("type_id") or "")) or structure.get(
|
if mapping.composite_for(str(structure.get("type_id") or ""), structure) or structure.get(
|
||||||
"unconfirmed"
|
"unconfirmed"
|
||||||
):
|
):
|
||||||
continue # 묶음 조각이 품음 · 기본값으로 선 구조물은 금액에 안 듦
|
continue # 묶음 조각이 품음 · 기본값으로 선 구조물은 금액에 안 듦
|
||||||
|
|||||||
@@ -254,6 +254,8 @@ def summary_input_rows(table: dict[str, Any]) -> list[dict[str, Any]]:
|
|||||||
{
|
{
|
||||||
"equipment": row["equipment"],
|
"equipment": row["equipment"],
|
||||||
"ground": row["ground"],
|
"ground": row["ground"],
|
||||||
|
# 암 갈래(구성비로 가른 줄 · `RockSplit`) — 집계표 공종 칸이 흙깎기와 같은 이름을 씀.
|
||||||
|
"rock_class": row.get("rock_class"),
|
||||||
"volume_m3": row["volume_m3"],
|
"volume_m3": row["volume_m3"],
|
||||||
"volume_basis": row.get("volume_basis") or "compacted",
|
"volume_basis": row.get("volume_basis") or "compacted",
|
||||||
"natural_m3": row.get("natural_m3"),
|
"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)
|
@dataclass(slots=True)
|
||||||
class HaulCheck:
|
class HaulCheck:
|
||||||
"""검산 — 무대를 안 내면 이 대조가 죽는다(PLAN 8-7 ㉡)."""
|
"""검산 — 무대를 안 내면 이 대조가 죽는다(PLAN 8-7 ㉡)."""
|
||||||
|
|||||||
@@ -369,6 +369,36 @@ def _extra_walls_at(extra_walls: dict[float, list[dict]] | None, chainage: float
|
|||||||
return extra_walls[best] if abs(best - chainage) <= SECTION_MATCH_TOLERANCE_M else []
|
return extra_walls[best] if abs(best - chainage) <= SECTION_MATCH_TOLERANCE_M else []
|
||||||
|
|
||||||
|
|
||||||
|
#: BOX암거 치수 칸 — 안 적으면 등록부 기본값(제안값)으로 서되 **미확정**(2026-09-14 브레인 판정 ·
|
||||||
|
#: 2026-09-15 12장 D). 연장은 기본값이 없어 여기 없음 — 비면 전개식이 사유를 냄.
|
||||||
|
BOX_DIMENSIONS = (
|
||||||
|
("body_width_m", "본체 폭"),
|
||||||
|
("body_height_m", "본체 높이"),
|
||||||
|
("wall_thickness_m", "측벽 두께"),
|
||||||
|
("slab_thickness_m", "상·저판 두께"),
|
||||||
|
("haunch_m", "헌치"),
|
||||||
|
("blinding_thickness_m", "기초(버림) 두께"),
|
||||||
|
)
|
||||||
|
UNCONFIRMED_BOX = "BOX암거 치수({filled})가 시설 지점에 없어 기본값으로 섰음 — 적으면 금액이 섬"
|
||||||
|
|
||||||
|
|
||||||
|
def _box_row(base: dict[str, Any], registry: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""BOX암거 줄 — 빈 치수 칸에 등록부 제안값을 채우고 그 사실을 미확정 사유로 붙임."""
|
||||||
|
options = dict(base["options"])
|
||||||
|
filled = []
|
||||||
|
for key, label in BOX_DIMENSIONS:
|
||||||
|
if _blank(options.get(key)) and registry.get(key) is not None:
|
||||||
|
options[key] = registry[key]
|
||||||
|
filled.append(label)
|
||||||
|
reason = UNCONFIRMED_BOX.format(filled=" · ".join(filled)) if filled else ""
|
||||||
|
return {
|
||||||
|
**base,
|
||||||
|
"options": options,
|
||||||
|
"unconfirmed": reason,
|
||||||
|
"notes": [f"⚠ {reason}"] if reason else [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def facility_structures(
|
def facility_structures(
|
||||||
points: list[dict[str, Any]],
|
points: list[dict[str, Any]],
|
||||||
extra_walls: dict[float, list[dict]] | None = None,
|
extra_walls: dict[float, list[dict]] | None = None,
|
||||||
@@ -405,8 +435,11 @@ def facility_structures(
|
|||||||
"end_m": point.get("end_m") if point.get("end_m") is not None else chainage,
|
"end_m": point.get("end_m") if point.get("end_m") is not None else chainage,
|
||||||
"options": options,
|
"options": options,
|
||||||
}
|
}
|
||||||
|
if facility == "box_culvert":
|
||||||
|
rows.append(_box_row(base, defaults(facility)))
|
||||||
|
continue
|
||||||
if facility not in (FACILITY_PIPE, "revetment"):
|
if facility not in (FACILITY_PIPE, "revetment"):
|
||||||
rows.append(base) # BOX암거·물넘이·세월교 — 전개식·관측값이 없으면 사유로 섬
|
rows.append(base) # 물넘이·세월교 — 전개식·관측값이 없으면 사유로 섬
|
||||||
continue
|
continue
|
||||||
legacy = facility == "revetment" # 독립 기슭막이 — 벽 칸 밖의 제원(뒷길이 등)도 벽이 씀
|
legacy = facility == "revetment" # 독립 기슭막이 — 벽 칸 밖의 제원(뒷길이 등)도 벽이 씀
|
||||||
wall_defaults = defaults(facility)
|
wall_defaults = defaults(facility)
|
||||||
|
|||||||
@@ -50,15 +50,21 @@ from B08_Quantity.B08_Quantity_Engine_Preparation_Ancillary import ( # noqa: E4
|
|||||||
ANCILLARY_ITEMS,
|
ANCILLARY_ITEMS,
|
||||||
ancillary_rows,
|
ancillary_rows,
|
||||||
)
|
)
|
||||||
|
from B08_Quantity.B08_Quantity_Engine_Preparation_FrameMaterial import ( # noqa: E402
|
||||||
|
FRAME_MATERIAL_SUGGESTED,
|
||||||
|
frame_material_rows,
|
||||||
|
)
|
||||||
from B08_Quantity.B08_Quantity_Engine_Preparation_Status import ( # noqa: E402
|
from B08_Quantity.B08_Quantity_Engine_Preparation_Status import ( # noqa: E402
|
||||||
STATUS_COUNTED_ELSEWHERE,
|
STATUS_COUNTED_ELSEWHERE,
|
||||||
|
STATUS_NEEDS_INPUT,
|
||||||
STATUS_NOT_APPLICABLE,
|
STATUS_NOT_APPLICABLE,
|
||||||
STATUS_PENDING,
|
STATUS_PENDING,
|
||||||
STATUS_READY,
|
STATUS_READY,
|
||||||
)
|
)
|
||||||
from B08_Quantity.B08_Quantity_Engine_Preparation_TreeWaste import tree_waste_rows # noqa: E402
|
from B08_Quantity.B08_Quantity_Engine_Preparation_TreeWaste import tree_waste_rows # noqa: E402
|
||||||
|
|
||||||
__all__ = ["ANCILLARY_ITEMS", "ancillary_rows"] # 갈라 나간 뒤에도 여기서 읽을 수 있게
|
# 갈라 나간 뒤에도 여기서 읽을 수 있게(부대시설 2026-09-09 · 규준틀 재료 2026-09-14).
|
||||||
|
__all__ = ["ANCILLARY_ITEMS", "FRAME_MATERIAL_SUGGESTED", "ancillary_rows", "frame_material_rows"]
|
||||||
|
|
||||||
|
|
||||||
def batter_frame_count(slope_rows: Iterable[dict[str, Any]]) -> tuple[int, list[str]]:
|
def batter_frame_count(slope_rows: Iterable[dict[str, Any]]) -> tuple[int, list[str]]:
|
||||||
@@ -170,7 +176,7 @@ def _topsoil_row(
|
|||||||
return {
|
return {
|
||||||
**base,
|
**base,
|
||||||
"amount": None,
|
"amount": None,
|
||||||
"status": STATUS_PENDING,
|
"status": STATUS_NEEDS_INPUT,
|
||||||
"reason": (
|
"reason": (
|
||||||
"노면 면적을 못 셉니다 — 횡단 설계에 노체 끝(노면 폭)이 없는 측점이 있음. "
|
"노면 면적을 못 셉니다 — 횡단 설계에 노체 끝(노면 폭)이 없는 측점이 있음. "
|
||||||
f"대상은 노면 + 절토대상지(별표2) · 절토 사면 {cut:,.1f}㎡ 만으로는 세우지 않음"
|
f"대상은 노면 + 절토대상지(별표2) · 절토 사면 {cut:,.1f}㎡ 만으로는 세우지 않음"
|
||||||
@@ -184,7 +190,7 @@ def _topsoil_row(
|
|||||||
return {
|
return {
|
||||||
**base,
|
**base,
|
||||||
"amount": None,
|
"amount": None,
|
||||||
"status": STATUS_PENDING,
|
"status": STATUS_NEEDS_INPUT,
|
||||||
"reason": (
|
"reason": (
|
||||||
"대상 면적이 0 ㎡ 입니다 — 횡단·사면표가 아직 서지 않았습니다. "
|
"대상 면적이 0 ㎡ 입니다 — 횡단·사면표가 아직 서지 않았습니다. "
|
||||||
"0 ㎡ 로 내면 「표토가 없는 노선」으로 읽히므로 값을 세우지 않습니다"
|
"0 ㎡ 로 내면 「표토가 없는 노선」으로 읽히므로 값을 세우지 않습니다"
|
||||||
@@ -309,7 +315,7 @@ def _root_removal_row(slope: dict[str, float], stand_volume_class: str | None) -
|
|||||||
"amount": area if area > 0 else None,
|
"amount": area if area > 0 else None,
|
||||||
# ⭐ 2026-09-13 판정 Ⓑ — 셈은 토공집계 「지장목제거 · 뿌리뽑기」(FP-09-21)가 한다.
|
# ⭐ 2026-09-13 판정 Ⓑ — 셈은 토공집계 「지장목제거 · 뿌리뽑기」(FP-09-21)가 한다.
|
||||||
# 여기는 **보이되 안 실린다**(같은 면적 · 같은 작업 — 또 세면 이중계상).
|
# 여기는 **보이되 안 실린다**(같은 면적 · 같은 작업 — 또 세면 이중계상).
|
||||||
"status": STATUS_COUNTED_ELSEWHERE if area > 0 else STATUS_PENDING,
|
"status": STATUS_COUNTED_ELSEWHERE if area > 0 else STATUS_NEEDS_INPUT,
|
||||||
"reason": " · ".join(reasons),
|
"reason": " · ".join(reasons),
|
||||||
"reference_amount": area,
|
"reference_amount": area,
|
||||||
"work_item_code": None,
|
"work_item_code": None,
|
||||||
@@ -364,7 +370,7 @@ def chipping_rows(enabled: Any, volume_m3: Any) -> list[dict[str, Any]]:
|
|||||||
"item": CHIPPING_ITEM,
|
"item": CHIPPING_ITEM,
|
||||||
"unit": "㎥",
|
"unit": "㎥",
|
||||||
"amount": amount,
|
"amount": amount,
|
||||||
"status": STATUS_READY if amount and amount > 0 else STATUS_PENDING,
|
"status": STATUS_READY if amount and amount > 0 else STATUS_NEEDS_INPUT,
|
||||||
"reason": CHIPPING_ON_NOTE if not amount else "산출 조건에서 넣은 부피 (확정 5차 5번)",
|
"reason": CHIPPING_ON_NOTE if not amount else "산출 조건에서 넣은 부피 (확정 5차 5번)",
|
||||||
"work_item_code": CHIPPING_CODE,
|
"work_item_code": CHIPPING_CODE,
|
||||||
}
|
}
|
||||||
@@ -382,7 +388,7 @@ def _root_steps_rows(
|
|||||||
"item": "뿌리 적재",
|
"item": "뿌리 적재",
|
||||||
"unit": "㎡",
|
"unit": "㎡",
|
||||||
"amount": area if area > 0 else None,
|
"amount": area if area > 0 else None,
|
||||||
"status": STATUS_READY if area > 0 else STATUS_PENDING,
|
"status": STATUS_READY if area > 0 else STATUS_NEEDS_INPUT,
|
||||||
"reason": (
|
"reason": (
|
||||||
f"{ROOT_STEPS_NOTE} · {ROOT_REMOVAL_BASIS}"
|
f"{ROOT_STEPS_NOTE} · {ROOT_REMOVAL_BASIS}"
|
||||||
" · ⚠ **품셈 9-20-2 는 「10주당」이라 밑수 축이 다름** — 면적 축으로 내고"
|
" · ⚠ **품셈 9-20-2 는 「10주당」이라 밑수 축이 다름** — 면적 축으로 내고"
|
||||||
@@ -434,7 +440,7 @@ def _topsoil_haul_row(
|
|||||||
"item": "표토 운반·적치",
|
"item": "표토 운반·적치",
|
||||||
"unit": "㎥",
|
"unit": "㎥",
|
||||||
"amount": None,
|
"amount": None,
|
||||||
"status": STATUS_PENDING,
|
"status": STATUS_NEEDS_INPUT,
|
||||||
"reason": f"{TOPSOIL_HAUL_LAW} · 제거 면적이 아직 안 서서 운반도 못 셈",
|
"reason": f"{TOPSOIL_HAUL_LAW} · 제거 면적이 아직 안 서서 운반도 못 셈",
|
||||||
"work_item_code": None,
|
"work_item_code": None,
|
||||||
}
|
}
|
||||||
@@ -444,7 +450,7 @@ def _topsoil_haul_row(
|
|||||||
"item": "표토 운반·적치",
|
"item": "표토 운반·적치",
|
||||||
"unit": "㎥",
|
"unit": "㎥",
|
||||||
"amount": None,
|
"amount": None,
|
||||||
"status": STATUS_PENDING,
|
"status": STATUS_NEEDS_INPUT,
|
||||||
"reason": (
|
"reason": (
|
||||||
f"{TOPSOIL_HAUL_LAW} · 운반 부피 = 제거 면적 × 표토 두께 — 두께가 아직 입력되지"
|
f"{TOPSOIL_HAUL_LAW} · 운반 부피 = 제거 면적 × 표토 두께 — 두께가 아직 입력되지"
|
||||||
f" 않았습니다. {TOPSOIL_ORIGINAL_APPLIED} (제거 면적 {float(area):,.1f}㎡)"
|
f" 않았습니다. {TOPSOIL_ORIGINAL_APPLIED} (제거 면적 {float(area):,.1f}㎡)"
|
||||||
@@ -472,7 +478,7 @@ def _topsoil_haul_row(
|
|||||||
"item": "표토 운반·적치",
|
"item": "표토 운반·적치",
|
||||||
"unit": "㎥",
|
"unit": "㎥",
|
||||||
"amount": None,
|
"amount": None,
|
||||||
"status": STATUS_PENDING,
|
"status": STATUS_NEEDS_INPUT,
|
||||||
"reason": (
|
"reason": (
|
||||||
f"{TOPSOIL_HAUL_LAW} · 운반거리가 아직 입력되지 않았습니다 — 「최고 홍수위보다"
|
f"{TOPSOIL_HAUL_LAW} · 운반거리가 아직 입력되지 않았습니다 — 「최고 홍수위보다"
|
||||||
f" 높은 장소」는 현장에서 정하는 자리라 품셈이 거리를 주지 않습니다"
|
f" 높은 장소」는 현장에서 정하는 자리라 품셈이 거리를 주지 않습니다"
|
||||||
@@ -505,88 +511,17 @@ def _topsoil_haul_row(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#: 규준틀 재료 — **세는 것은 확정이고 수량만 몰랐던 자리**(2026-09-09).
|
|
||||||
#: 품셈 11-2·11-3 [주]④ 「재료량은 **설계수량에 따른다**」 ⇒ 품셈이 값을 안 주는 것이지
|
|
||||||
#: 「안 센다」가 아니다. 그래서 **제안값을 보이고 사용자가 고치는** 모양으로 둔다
|
|
||||||
#: (확정 ⑨·⑩ 과 같은 틀 — 「가는 기본값이고 나 선택처럼 동작할 수 있어야 함」).
|
|
||||||
#: ⚠ **제안값은 실무 관측값이지 법정 기준이 아니다** — 울진 소광 원단위 라이브러리 §8
|
|
||||||
#: 「규준틀 수평 | 개소 | 각재 50×50 0.0044㎥ · 판재 T12 0.0029㎥ · 못 0.03㎏」.
|
|
||||||
#: ⚠ **비탈 규준틀 값은 그 시트에 없다** — 수평 값을 준용하고 그 사실을 사유에 적는다.
|
|
||||||
#: ⚠ **손율은 원문에 있다** — 품셈 11-2 [주]③ 비탈 **50%** · 11-3 [주]③ 수평 **80%**.
|
|
||||||
FRAME_MATERIAL_SUGGESTED = {
|
|
||||||
"각재 50×50": (0.0044, "㎥"),
|
|
||||||
"판재 T12": (0.0029, "㎥"),
|
|
||||||
"못": (0.03, "㎏"),
|
|
||||||
}
|
|
||||||
FRAME_MATERIAL_SOURCE = (
|
|
||||||
"⚠ 실무 관측값(울진 소광 원단위 라이브러리 §8 규준틀 수평) — **법정 기준 아님**."
|
|
||||||
" 품셈 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(
|
|
||||||
frame_rows: list[dict[str, Any]], overrides: dict[str, Any] | None = None
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""규준틀 재료 — 개소 × 개소당 수량. **자재 축으로 보낸다.**
|
|
||||||
|
|
||||||
⚠ 개소가 안 서면 재료도 안 선다(밑수가 그 줄이다).
|
|
||||||
⚠ 값은 **제안값**이고 산출 조건에서 덮어쓸 수 있다 — 그 사실이 사유에 적힌다.
|
|
||||||
"""
|
|
||||||
given = overrides or {}
|
|
||||||
rows: list[dict[str, Any]] = []
|
|
||||||
for frame in frame_rows:
|
|
||||||
count = frame.get("amount")
|
|
||||||
if not count:
|
|
||||||
continue
|
|
||||||
loss = FRAME_LOSS_RATE.get(str(frame.get("item")), None)
|
|
||||||
for name, (default, unit) in FRAME_MATERIAL_SUGGESTED.items():
|
|
||||||
raw = given.get(name)
|
|
||||||
try:
|
|
||||||
per_ea = (
|
|
||||||
float(raw) if raw is not None and str(raw).strip() != "" else float(default)
|
|
||||||
)
|
|
||||||
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": material,
|
|
||||||
"spec": spec,
|
|
||||||
"unit": unit,
|
|
||||||
"amount": float(count) * per_ea,
|
|
||||||
"destination": "material",
|
|
||||||
"source": str(frame.get("item") or "규준틀"),
|
|
||||||
"basis": (
|
|
||||||
f"{frame.get('item')} {float(count):g}개소 × {per_ea:g}{unit}/개소"
|
|
||||||
f" ({picked}) · {FRAME_MATERIAL_SOURCE}"
|
|
||||||
# ⚠ 준용이라는 사실이 상수 주석에만 있고 **화면 근거에는 없던**
|
|
||||||
# 자리다 — 값이 서면 어디서 온 값인지 안 보인다(2026-09-09 감사).
|
|
||||||
+ (
|
|
||||||
" · ⚠ 비탈 규준틀 재료량은 그 시트에 없어 **수평 값을 준용**함"
|
|
||||||
if str(frame.get("item")) == "비탈 규준틀"
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
+ (f" · 손율 {loss}%(품셈 [주]③)" if loss else "")
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
def _batter_frame_row(slope_rows: list[dict[str, Any]]) -> dict[str, Any]:
|
def _batter_frame_row(slope_rows: list[dict[str, Any]]) -> dict[str, Any]:
|
||||||
"""비탈 규준틀 한 줄. **개소는 원문 기준으로 서고 재료는 미확보**다."""
|
"""비탈 규준틀 한 줄. **개소는 원문 기준으로 서고 재료는 미확보**다."""
|
||||||
count, notes = batter_frame_count(slope_rows)
|
count, notes = batter_frame_count(slope_rows)
|
||||||
|
# 사면표가 있는데 0 개소면 **이 노선엔 필요 없는 것**이다 — 「근거 없음」이 아니다(㉴).
|
||||||
|
idle = STATUS_NOT_APPLICABLE if slope_rows else STATUS_NEEDS_INPUT
|
||||||
return {
|
return {
|
||||||
"group": "준비공",
|
"group": "준비공",
|
||||||
"item": "비탈 규준틀",
|
"item": "비탈 규준틀",
|
||||||
"unit": "개소",
|
"unit": "개소",
|
||||||
"amount": float(count) if count else None,
|
"amount": float(count) if count else None,
|
||||||
"status": STATUS_READY if count else STATUS_PENDING,
|
"status": STATUS_READY if count else idle,
|
||||||
"reason": (
|
"reason": (
|
||||||
"; ".join(notes)
|
"; ".join(notes)
|
||||||
+ " · 재료량은 품셈 11-2 [주]④ 「설계수량에 따른다」 —"
|
+ " · 재료량은 품셈 11-2 [주]④ 「설계수량에 따른다」 —"
|
||||||
@@ -604,7 +539,12 @@ def _level_frame_row(slope_rows: list[dict[str, Any]]) -> dict[str, Any]:
|
|||||||
"item": "수평 규준틀",
|
"item": "수평 규준틀",
|
||||||
"unit": "개소",
|
"unit": "개소",
|
||||||
"amount": float(count) if count is not None else None,
|
"amount": float(count) if count is not None else None,
|
||||||
"status": STATUS_READY if count is not None else STATUS_PENDING,
|
# 사면표가 안 섰으면 앞 단계 몫(입력) · 섰는데 성토고 칸이 없으면 우리 자료가 없는 것.
|
||||||
|
"status": STATUS_READY
|
||||||
|
if count is not None
|
||||||
|
else STATUS_PENDING
|
||||||
|
if slope_rows
|
||||||
|
else STATUS_NEEDS_INPUT,
|
||||||
"reason": "; ".join(notes)
|
"reason": "; ".join(notes)
|
||||||
+ (
|
+ (
|
||||||
" · 재료량은 품셈 11-3 [주]④ 「설계수량에 따른다」 —"
|
" · 재료량은 품셈 11-3 [주]④ 「설계수량에 따른다」 —"
|
||||||
@@ -705,6 +645,7 @@ def build_table(
|
|||||||
"columns": ["구분", "공종", "단위", "수량", "상태", "사유"],
|
"columns": ["구분", "공종", "단위", "수량", "상태", "사유"],
|
||||||
"rows": rows,
|
"rows": rows,
|
||||||
"ready_count": sum(1 for row in rows if row["status"] == STATUS_READY),
|
"ready_count": sum(1 for row in rows if row["status"] == STATUS_READY),
|
||||||
|
"input_count": sum(1 for row in rows if row["status"] == STATUS_NEEDS_INPUT),
|
||||||
"pending_count": sum(1 for row in rows if row["status"] == STATUS_PENDING),
|
"pending_count": sum(1 for row in rows if row["status"] == STATUS_PENDING),
|
||||||
"row_count": len(rows),
|
"row_count": len(rows),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,14 @@ from typing import Any
|
|||||||
|
|
||||||
from B08_Quantity.B08_Quantity_Engine_Preparation_Status import (
|
from B08_Quantity.B08_Quantity_Engine_Preparation_Status import (
|
||||||
REASON_NO_WORK_ITEM,
|
REASON_NO_WORK_ITEM,
|
||||||
STATUS_PENDING,
|
STATUS_NEEDS_INPUT,
|
||||||
|
STATUS_NO_WORK_ITEM,
|
||||||
STATUS_READY,
|
STATUS_READY,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
#: 공종 없는 줄에 개소가 들어온 뒤 할 일 — 자재총괄(사급) 줄로 가서 「자재 단가」 탭에 칸이 섬.
|
||||||
|
NO_WORK_ITEM_PRICE_PATH = "「자재 단가」 탭에 단가를 넣으면 자재총괄(사급) 줄로 금액이 섬"
|
||||||
|
|
||||||
#: 부대시설·가설공사 — **법이 요구하는데 우리가 안 내던 다섯 줄**(2026-09-09 사용자 확정 ⑬).
|
#: 부대시설·가설공사 — **법이 요구하는데 우리가 안 내던 다섯 줄**(2026-09-09 사용자 확정 ⑬).
|
||||||
#: `key` 는 설정의 `ancillary_counts` 칸 이름, `code` 는 품셈 공종(없으면 `None`).
|
#: `key` 는 설정의 `ancillary_counts` 칸 이름, `code` 는 품셈 공종(없으면 `None`).
|
||||||
#: ⚠ 다섯 중 **품셈에 공종이 있는 것은 가설창고 하나뿐**이다(마스터 전수 확인).
|
#: ⚠ 다섯 중 **품셈에 공종이 있는 것은 가설창고 하나뿐**이다(마스터 전수 확인).
|
||||||
@@ -94,9 +98,12 @@ def ancillary_rows(counts: dict[str, Any] | None = None) -> list[dict[str, Any]]
|
|||||||
reasons.append(REASON_NO_WORK_ITEM)
|
reasons.append(REASON_NO_WORK_ITEM)
|
||||||
if amount is None:
|
if amount is None:
|
||||||
reasons.append("개소가 아직 입력되지 않았습니다 — 넣으면 물량이 섭니다")
|
reasons.append("개소가 아직 입력되지 않았습니다 — 넣으면 물량이 섭니다")
|
||||||
status = STATUS_PENDING
|
status = STATUS_NEEDS_INPUT
|
||||||
|
elif spec["code"]:
|
||||||
|
status = STATUS_READY
|
||||||
else:
|
else:
|
||||||
status = STATUS_READY if spec["code"] else STATUS_PENDING
|
reasons.append(NO_WORK_ITEM_PRICE_PATH)
|
||||||
|
status = STATUS_NO_WORK_ITEM
|
||||||
rows.append(
|
rows.append(
|
||||||
{
|
{
|
||||||
"group": "부대시설",
|
"group": "부대시설",
|
||||||
@@ -110,3 +117,22 @@ def ancillary_rows(counts: dict[str, Any] | None = None) -> list[dict[str, Any]]
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def ancillary_material_rows(preparation_rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
"""공종 없는 부대시설 중 개소가 선 줄 → 자재총괄 성분(사급 기본) — 「자재 단가」 탭 통로.
|
||||||
|
|
||||||
|
⚠ 인계 공종 줄은 막힘 없이 `in_bill: False` 로 가므로 **여기 한 곳에서만** 금액이 선다.
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": row["item"],
|
||||||
|
"spec": "",
|
||||||
|
"unit": row["unit"],
|
||||||
|
"amount": float(row["amount"]),
|
||||||
|
"destination": "material",
|
||||||
|
"source": str(row.get("group") or "부대시설"),
|
||||||
|
}
|
||||||
|
for row in preparation_rows
|
||||||
|
if row.get("status") == STATUS_NO_WORK_ITEM and row.get("amount")
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""준비공 — 규준틀 재료 줄 (`B08_Quantity_Engine_Preparation` 에서 갈라냄 · 2026-09-14 700줄 제한).
|
||||||
|
|
||||||
|
내용·규칙은 그대로 옮겼다 — 개소 × 개소당 수량을 자재 축으로 보내고,
|
||||||
|
값은 제안값이며 산출 조건이 이긴다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
#: 규준틀 재료 — **세는 것은 확정이고 수량만 몰랐던 자리**(2026-09-09).
|
||||||
|
#: 품셈 11-2·11-3 [주]④ 「재료량은 **설계수량에 따른다**」 ⇒ 품셈이 값을 안 주는 것이지
|
||||||
|
#: 「안 센다」가 아니다. 그래서 **제안값을 보이고 사용자가 고치는** 모양으로 둔다
|
||||||
|
#: (확정 ⑨·⑩ 과 같은 틀 — 「가는 기본값이고 나 선택처럼 동작할 수 있어야 함」).
|
||||||
|
#: ⚠ **제안값은 실무 관측값이지 법정 기준이 아니다** — 울진 소광 원단위 라이브러리 §8
|
||||||
|
#: 「규준틀 수평 | 개소 | 각재 50×50 0.0044㎥ · 판재 T12 0.0029㎥ · 못 0.03㎏」.
|
||||||
|
#: ⚠ **비탈 규준틀 값은 그 시트에 없다** — 수평 값을 준용하고 그 사실을 사유에 적는다.
|
||||||
|
#: ⚠ **손율은 원문에 있다** — 품셈 11-2 [주]③ 비탈 **50%** · 11-3 [주]③ 수평 **80%**.
|
||||||
|
FRAME_MATERIAL_SUGGESTED = {
|
||||||
|
"각재 50×50": (0.0044, "㎥"),
|
||||||
|
"판재 T12": (0.0029, "㎥"),
|
||||||
|
"못": (0.03, "㎏"),
|
||||||
|
}
|
||||||
|
FRAME_MATERIAL_SOURCE = (
|
||||||
|
"⚠ 실무 관측값(울진 소광 원단위 라이브러리 §8 규준틀 수평) — **법정 기준 아님**."
|
||||||
|
" 품셈 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(
|
||||||
|
frame_rows: list[dict[str, Any]], overrides: dict[str, Any] | None = None
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""규준틀 재료 — 개소 × 개소당 수량. **자재 축으로 보낸다.**
|
||||||
|
|
||||||
|
⚠ 개소가 안 서면 재료도 안 선다(밑수가 그 줄이다).
|
||||||
|
⚠ 값은 **제안값**이고 산출 조건에서 덮어쓸 수 있다 — 그 사실이 사유에 적힌다.
|
||||||
|
"""
|
||||||
|
given = overrides or {}
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
for frame in frame_rows:
|
||||||
|
count = frame.get("amount")
|
||||||
|
if not count:
|
||||||
|
continue
|
||||||
|
loss = FRAME_LOSS_RATE.get(str(frame.get("item")), None)
|
||||||
|
for name, (default, unit) in FRAME_MATERIAL_SUGGESTED.items():
|
||||||
|
raw = given.get(name)
|
||||||
|
try:
|
||||||
|
per_ea = (
|
||||||
|
float(raw) if raw is not None and str(raw).strip() != "" else float(default)
|
||||||
|
)
|
||||||
|
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": material,
|
||||||
|
"spec": spec,
|
||||||
|
"unit": unit,
|
||||||
|
"amount": float(count) * per_ea,
|
||||||
|
"destination": "material",
|
||||||
|
"source": str(frame.get("item") or "규준틀"),
|
||||||
|
"basis": (
|
||||||
|
f"{frame.get('item')} {float(count):g}개소 × {per_ea:g}{unit}/개소"
|
||||||
|
f" ({picked}) · {FRAME_MATERIAL_SOURCE}"
|
||||||
|
# ⚠ 준용이라는 사실이 상수 주석에만 있고 **화면 근거에는 없던**
|
||||||
|
# 자리다 — 값이 서면 어디서 온 값인지 안 보인다(2026-09-09 감사).
|
||||||
|
+ (
|
||||||
|
" · ⚠ 비탈 규준틀 재료량은 그 시트에 없어 **수평 값을 준용**함"
|
||||||
|
if str(frame.get("item")) == "비탈 규준틀"
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
+ (f" · 손율 {loss}%(품셈 [주]③)" if loss else "")
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
@@ -8,7 +8,17 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
STATUS_READY = "값 있음"
|
STATUS_READY = "값 있음"
|
||||||
|
#: 사람이 넣으면 서는 줄 — 산출 조건 칸, 또는 앞 단계(횡단·사면표)를 마치면 섬.
|
||||||
|
#: 인계 `input_missing`.
|
||||||
|
#: ⚠ 「근거 없음」과 가른다 — 한 낱말로 덮으면 입력만 넣으면 서는 줄이 「못 세움」으로 읽힌다
|
||||||
|
#: (2026-09-14 브레인 ㉴ · 인계는 반대로 사방 원단위까지 「입력이 필요합니다」로 보냈다).
|
||||||
|
STATUS_NEEDS_INPUT = "입력이 필요함"
|
||||||
|
#: 자료·산식이 우리에게 없는 줄 — 입력으로는 안 풀림. 인계 `unit_data_missing`.
|
||||||
STATUS_PENDING = "값을 낼 근거가 없음"
|
STATUS_PENDING = "값을 낼 근거가 없음"
|
||||||
|
#: 수량은 섰는데 **품셈에 공종이 없어** 단가가 영영 안 서는 줄 — 「자재 단가」 탭에 단가를 넣음
|
||||||
|
#: (화약류·치즐과 같은 통로 · 자재총괄 사급 줄로 감). 인계는 막힘이 아님(자재 줄로 셈).
|
||||||
|
#: ⚠ 「입력이 필요함」으로 두면 개소를 넣고 기다려도 안 섬(2026-09-14 브레인 판정).
|
||||||
|
STATUS_NO_WORK_ITEM = "품셈 공종 없음 — 단가를 직접 넣어야 함"
|
||||||
STATUS_COUNTED_ELSEWHERE = "다른 표에서 이미 섬"
|
STATUS_COUNTED_ELSEWHERE = "다른 표에서 이미 섬"
|
||||||
STATUS_NOT_APPLICABLE = "해당 없음"
|
STATUS_NOT_APPLICABLE = "해당 없음"
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ from __future__ import annotations
|
|||||||
import math
|
import math
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from B08_Quantity.B08_Quantity_Engine_Preparation_Status import STATUS_PENDING, STATUS_READY
|
from B08_Quantity.B08_Quantity_Engine_Preparation_Status import STATUS_NEEDS_INPUT, STATUS_READY
|
||||||
|
|
||||||
TREE_WASTE_ITEM = "임목폐기물 처리"
|
TREE_WASTE_ITEM = "임목폐기물 처리"
|
||||||
STEM_FORM_FACTOR = 0.5 # k
|
STEM_FORM_FACTOR = 0.5 # k
|
||||||
@@ -139,7 +139,7 @@ def tree_waste_rows(
|
|||||||
{
|
{
|
||||||
**base,
|
**base,
|
||||||
"amount": None,
|
"amount": None,
|
||||||
"status": STATUS_PENDING,
|
"status": STATUS_NEEDS_INPUT, # 조사값 칸 · 앞 단계 사면표 — 둘 다 사람 몫
|
||||||
"reason": f"{why}. 산식: {TREE_WASTE_BASIS} · {root_basis}",
|
"reason": f"{why}. 산식: {TREE_WASTE_BASIS} · {root_basis}",
|
||||||
"reference_amount": area,
|
"reference_amount": area,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -19,13 +19,36 @@ from typing import Any
|
|||||||
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import TEMPLATE_DIR
|
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import TEMPLATE_DIR
|
||||||
from config.config_system import STORAGE_BASE_DIR
|
from config.config_system import STORAGE_BASE_DIR
|
||||||
|
|
||||||
#: 단 이름 — 고르개에 이 차례로 보임(가까운 것부터).
|
#: 단 이름 — 고르개에 이 차례로 보임(가까운 것부터). `received` = 동료가 보낸 것(공유 · 개인 단을 안 덮음).
|
||||||
TIERS = ("personal", "company", "program")
|
TIERS = ("personal", "received", "company", "program")
|
||||||
|
RECEIVED_SUBDIR = "library_received"
|
||||||
#: 항목 코드 — 파일 이름이 되므로 모양을 먼저 봄(경로 벗어남 막이).
|
#: 항목 코드 — 파일 이름이 되므로 모양을 먼저 봄(경로 벗어남 막이).
|
||||||
CODE_PATTERN = re.compile(r"^AX-ST-[0-9a-f]{8}$")
|
CODE_PATTERN = re.compile(r"^AX-ST-[0-9a-f]{8}$")
|
||||||
LIBRARY_SUBDIR = "library"
|
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]:
|
def tier_dirs(company_id: Any, user_id: Any) -> dict[str, Path]:
|
||||||
"""로그인한 사람의 3단 폴더. 회사가 없으면(시스템 관리자) 개인·회사 단은 없음."""
|
"""로그인한 사람의 3단 폴더. 회사가 없으면(시스템 관리자) 개인·회사 단은 없음."""
|
||||||
dirs: dict[str, Path] = {}
|
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)
|
company = Path(STORAGE_BASE_DIR).resolve() / str(company_id)
|
||||||
if user_id is not None:
|
if user_id is not None:
|
||||||
dirs["personal"] = company / str(user_id) / LIBRARY_SUBDIR
|
dirs["personal"] = company / str(user_id) / LIBRARY_SUBDIR
|
||||||
|
dirs["received"] = company / str(user_id) / RECEIVED_SUBDIR
|
||||||
dirs["company"] = company / LIBRARY_SUBDIR
|
dirs["company"] = company / LIBRARY_SUBDIR
|
||||||
dirs["program"] = TEMPLATE_DIR
|
dirs["program"] = program_library_dir()
|
||||||
return dirs
|
return dirs
|
||||||
|
|
||||||
|
|
||||||
@@ -44,6 +68,13 @@ def _items(folder: Path) -> list[dict[str, Any]]:
|
|||||||
return [json.loads(p.read_text(encoding="utf-8")) for p in sorted(folder.glob("*.json"))]
|
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:
|
def item_kind(item: dict[str, Any]) -> str:
|
||||||
"""양식형(`form`) · 고정형(`fixed`) — 명세 13장: 칸은 같고 **식이 한 줄이라도 있나**로만 가름.
|
"""양식형(`form`) · 고정형(`fixed`) — 명세 13장: 칸은 같고 **식이 한 줄이라도 있나**로만 가름.
|
||||||
저장된 `item_kind` 표시는 안 믿음 — 줄과 어긋나면 배지가 거짓이 됨."""
|
저장된 `item_kind` 표시는 안 믿음 — 줄과 어긋나면 배지가 거짓이 됨."""
|
||||||
@@ -66,16 +97,15 @@ def list_items(dirs: dict[str, Path], type_id: str) -> list[dict[str, Any]]:
|
|||||||
}
|
}
|
||||||
for tier in TIERS
|
for tier in TIERS
|
||||||
if tier in dirs
|
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")
|
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:
|
def find_item(dirs: dict[str, Path], tier: str, code: str) -> dict[str, Any] | None:
|
||||||
folder = dirs.get(tier)
|
if tier not in dirs:
|
||||||
if folder is None:
|
|
||||||
return None
|
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:
|
def project_library_dir(project_root: str | Path) -> Path:
|
||||||
@@ -98,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:
|
def import_item(project_root: str | Path, item: dict[str, Any], tier: str) -> None:
|
||||||
@@ -127,9 +157,13 @@ def _write(folder: Path, item: dict[str, Any]) -> None:
|
|||||||
(folder / f"{item['code']}.json").write_text(text, encoding="utf-8")
|
(folder / f"{item['code']}.json").write_text(text, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def _personal_code(folder: Path, type_id: Any) -> str:
|
def _personal_code(folder: Path, type_id: Any, existing: list[dict[str, Any]] | None = None) -> str:
|
||||||
"""개인 단 코드 — 같은 종류가 있으면 그 코드(덮어씀 · 판정 Ⓐ 종류당 하나), 없으면 새로."""
|
"""그 단 코드 — 같은 종류가 있으면 그 코드(덮어씀 · 판정 Ⓐ 종류당 하나), 없으면 새로.
|
||||||
same = [item for item in _items(folder) if item.get("type_id") == type_id]
|
|
||||||
|
프로그램 기본은 씨앗까지 봄(`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)}"
|
return str(same[0]["code"]) if same else f"AX-ST-{secrets.token_hex(4)}"
|
||||||
|
|
||||||
|
|
||||||
@@ -145,16 +179,22 @@ def save_personal(
|
|||||||
template: dict[str, Any],
|
template: dict[str, Any],
|
||||||
overrides: dict[str, Any] | None,
|
overrides: dict[str, Any] | None,
|
||||||
unit_price_rows: list[dict[str, Any]] | None = None,
|
unit_price_rows: list[dict[str, Any]] | None = None,
|
||||||
|
tier: str = "personal",
|
||||||
|
name: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""[내 라이브러리에 저장] — 양식 + 고친 식·줄 조합을 **개인 단에 한 벌**로 씀. 코드.
|
"""[내 라이브러리에 저장]·발행 — 양식 + 고친 식·줄 조합을 **그 단에 한 벌**로 씀. 코드.
|
||||||
|
|
||||||
⚠ 반대 방향(작업본 → 개인 단)이라 프로젝트는 안 바꿈(브레인 판정 Ⓑ).
|
⚠ `name` — 이름표(화면이 「종류 + 제원 요약」을 제안 · 사용자가 고침 · 10-A ⑫). 비면 양식 이름.
|
||||||
⚠ 개인 단에 같은 종류가 있으면 **그 코드로 덮어씀** — 지금은 종류당 하나(판정 Ⓐ).
|
|
||||||
|
⚠ 반대 방향(작업본 → 라이브러리)이라 프로젝트는 안 바꿈(브레인 판정 Ⓑ).
|
||||||
|
⚠ 같은 종류가 있으면 **그 코드로 덮어씀** — 지금은 종류당 하나(판정 Ⓐ).
|
||||||
|
⚠ `tier` — 회사(MASTER)·프로그램 기본(SYSTEM_ADMIN) 발행도 같은 모양(2026-09-14 브레인 승인).
|
||||||
⛔ 수동 단가는 안 실음 — 프로젝트의 값이라 양식에 실으면 남의 프로젝트로 감(브레인 판정).
|
⛔ 수동 단가는 안 실음 — 프로젝트의 값이라 양식에 실으면 남의 프로젝트로 감(브레인 판정).
|
||||||
"""
|
"""
|
||||||
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import overridden_rows
|
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import overridden_rows
|
||||||
|
|
||||||
code = _personal_code(folder, template.get("type_id"))
|
existing = program_items() if tier == "program" else None
|
||||||
|
code = _personal_code(folder, template.get("type_id"), existing)
|
||||||
# 고친 식·반올림이 곧 이 항목의 값 — 「사용자」 표시와 되돌릴 자리는 떼어 냄.
|
# 고친 식·반올림이 곧 이 항목의 값 — 「사용자」 표시와 되돌릴 자리는 떼어 냄.
|
||||||
dropped = {"default_formula", "default_rounding"}
|
dropped = {"default_formula", "default_rounding"}
|
||||||
rows = [
|
rows = [
|
||||||
@@ -162,9 +202,15 @@ def save_personal(
|
|||||||
for row in overridden_rows(template, overrides)
|
for row in overridden_rows(template, overrides)
|
||||||
]
|
]
|
||||||
item = {k: v for k, v in template.items() if k != "imported_from"}
|
item = {k: v for k, v in template.items() if k != "imported_from"}
|
||||||
|
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:
|
if unit_price_rows is not None:
|
||||||
item["unit_price"] = {**(item.get("unit_price") or {}), "rows": unit_price_rows}
|
item["unit_price"] = {**(item.get("unit_price") or {}), "rows": unit_price_rows}
|
||||||
_write(folder, {**item, "code": code, "library_tier": "personal", "rows": rows})
|
_write(folder, {**item, "code": code, "library_tier": tier, "rows": rows})
|
||||||
return code
|
return code
|
||||||
|
|
||||||
|
|
||||||
@@ -186,8 +232,18 @@ def pin_program_templates(project_root: str | Path) -> int:
|
|||||||
"""
|
"""
|
||||||
pinned = project_templates(project_root)
|
pinned = project_templates(project_root)
|
||||||
count = 0
|
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:
|
if item.get("type_id") and item.get("code") and item["type_id"] not in pinned:
|
||||||
import_item(project_root, item, "program")
|
import_item(project_root, item, "program")
|
||||||
count += 1
|
count += 1
|
||||||
return count
|
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"])
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -28,11 +28,13 @@ OVERRIDES_KEY = "structure_formula_overrides"
|
|||||||
|
|
||||||
|
|
||||||
def load_template(type_id: str) -> dict[str, Any] | None:
|
def load_template(type_id: str) -> dict[str, Any] | None:
|
||||||
"""프로그램 기본 양식. 없는 종류면 `None` — 그 종류는 지금 전개(고정형 모양)로 섬."""
|
"""프로그램 기본 양식. 없는 종류면 `None` — 그 종류는 지금 전개(고정형 모양)로 섬.
|
||||||
path = TEMPLATE_DIR / f"{type_id}.json"
|
|
||||||
if not path.is_file():
|
storage 작업본(시스템 관리자가 고친 것)이 먼저, 없으면 git 씨앗(`TEMPLATE_DIR`) — 2026-09-14 관리자 UI.
|
||||||
return None
|
"""
|
||||||
return json.loads(path.read_text(encoding="utf-8"))
|
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:
|
def template_of(type_id: str, templates: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||||
@@ -153,6 +155,65 @@ def template_sheet(
|
|||||||
#: 양식이 m당으로 풀리는 단위 — 연장 L=1 로 풀면 곧 단위당 값(모든 줄이 L 에 비례).
|
#: 양식이 m당으로 풀리는 단위 — 연장 L=1 로 풀면 곧 단위당 값(모든 줄이 L 에 비례).
|
||||||
_PER_LENGTH_UNITS = frozenset({"m"})
|
_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(
|
def _library_rows(
|
||||||
body: dict[str, Any], solved: list[dict[str, Any]], billing: float
|
body: dict[str, Any], solved: list[dict[str, Any]], billing: float
|
||||||
@@ -295,7 +356,11 @@ def replace_with_templates(
|
|||||||
spec=str(result.get("spec") or ""),
|
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)
|
quantity.library_item = str(template.get("name") or quantity.type_id)
|
||||||
|
|
||||||
@@ -343,18 +408,41 @@ def apply_templates(
|
|||||||
continue
|
continue
|
||||||
members = sheet.get("members") or []
|
members = sheet.get("members") or []
|
||||||
billing = float(members[0].get("billing_quantity") or 0.0) if members else 0.0
|
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"] = [
|
sheet["unpriced_rows"] = [
|
||||||
row["name"]
|
row["name"]
|
||||||
for row in sheet["rows"]
|
for row in sheet["rows"]
|
||||||
if row["unit_amount"] is None and not row["skipped"]
|
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` 없음(= 기본 · 가져오기 전).
|
# 「어느 단에서 가져왔나」 — 안 가져왔으면 `imported_from` 없음(= 기본 · 가져오기 전).
|
||||||
sheet["library_item"] = {
|
sheet["library_item"] = {
|
||||||
"type_id": template.get("type_id"),
|
"type_id": template.get("type_id"),
|
||||||
"name": template.get("name"),
|
"name": template.get("name"),
|
||||||
"code": template.get("code"),
|
"code": template.get("code"),
|
||||||
"imported_from": template.get("imported_from"),
|
"imported_from": template.get("imported_from"),
|
||||||
|
# 발행 확인창이 「○○공사에서 뽑은 것 — 공사명은 빼고 발행」을 보이게(브레인 ②).
|
||||||
|
"origin_project": (template.get("origin") or {}).get("project"),
|
||||||
}
|
}
|
||||||
sheet["formula_sheet"] = body
|
sheet["formula_sheet"] = body
|
||||||
# 실무 관측값 같은 「대안 후보」 — 값을 바꾸지 않고 칸 옆에 보이기만(판정 Ⓑ).
|
# 실무 관측값 같은 「대안 후보」 — 값을 바꾸지 않고 칸 옆에 보이기만(판정 Ⓑ).
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from collections.abc import Callable, Iterable
|
from collections.abc import Callable, Iterable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
@@ -362,32 +363,64 @@ def save_manual(
|
|||||||
return merged
|
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]]:
|
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
|
from B09_Estimation.B09_Estimation_PriceBook import PriceBookError
|
||||||
|
|
||||||
kinds = SEARCH_KINDS[kind]
|
kinds = SEARCH_KINDS[kind]
|
||||||
words = query.lower().split()
|
words = query.lower().split()
|
||||||
found: list[dict[str, Any]] = []
|
# 조각 비교는 글자·숫자만 — 괄호 「(장비)」 같은 기호가 겹친 글자 수를 부풀리지 않게.
|
||||||
for title in book.titles.values() if words else ():
|
compact = re.sub(r"[\W_]+", "", query.lower())
|
||||||
if len(found) >= limit:
|
scored: list[tuple[int, int, int, Any]] = []
|
||||||
break
|
for order, title in enumerate(book.titles.values() if words else ()):
|
||||||
if title.kind.value not in kinds:
|
if title.kind.value not in kinds:
|
||||||
continue
|
continue
|
||||||
text = f"{title.code} {title.name} {title.spec}".lower()
|
text = f"{title.code} {title.name} {title.spec}".lower()
|
||||||
if all(word in text for word in words):
|
if all(word in text for word in words):
|
||||||
try:
|
scored.append((1, len(compact), order, title))
|
||||||
money = book.resolve(title.code)
|
continue
|
||||||
total: float | None = float(money.material + money.labor + money.expense)
|
overlap = _longest_common(
|
||||||
except PriceBookError:
|
compact, re.sub(r"[\W_]+", "", f"{title.name}{title.spec}".lower())
|
||||||
total = None
|
)
|
||||||
found.append(
|
if overlap >= MIN_FRAGMENT:
|
||||||
{
|
scored.append((0, overlap, order, title))
|
||||||
"code": title.code,
|
scored.sort(key=lambda item: (-item[0], -item[1], item[2]))
|
||||||
"name": title.name,
|
found: list[dict[str, Any]] = []
|
||||||
"spec": title.spec,
|
for _whole, _overlap, _order, title in scored[:limit]:
|
||||||
"unit": _unit(title.unit),
|
try:
|
||||||
"price": total,
|
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
|
return found
|
||||||
|
|||||||
@@ -116,6 +116,10 @@ EXPANDERS = {
|
|||||||
"soil_guard": lambda h, l, o, f=None, r="": _soil_guard()(o),
|
"soil_guard": lambda h, l, o, f=None, r="": _soil_guard()(o),
|
||||||
# 바닥막이는 **돌붙임 ㎡당**이라 높이·연장이 아니라 **면적**이 밑수다(정본 「돌붙임L3=…」).
|
# 바닥막이는 **돌붙임 ㎡당**이라 높이·연장이 아니라 **면적**이 밑수다(정본 「돌붙임L3=…」).
|
||||||
"bed_sill": lambda h, l, o, f=None, r="": _bed_sill()(_num(o.get("area_m2"), 0.0), o),
|
"bed_sill": lambda h, l, o, f=None, r="": _bed_sill()(_num(o.get("area_m2"), 0.0), o),
|
||||||
|
# BOX암거 — m당 × 연장(산림과임업기술 예제 · 울진 2×2 · 2026-09-15 브레인 12장 D).
|
||||||
|
"box_culvert": lambda h, l, o, f=None, r="": _box_culvert()(o),
|
||||||
|
# 콘크리트 포장 — 면적 × 두께 · 거푸집 2L · 수축줄눈(교본 3-2 · 부록 4-7 · 소광 · 2026-09-15).
|
||||||
|
"pavement_concrete": lambda h, l, o, f=None, r="": _pavement().concrete_pavement(l, o),
|
||||||
# ⚠⚠ **큰돌쌓기(`boulder_masonry`)를 여기에 두지 않는다** (2026-09-07 발견).
|
# ⚠⚠ **큰돌쌓기(`boulder_masonry`)를 여기에 두지 않는다** (2026-09-07 발견).
|
||||||
# 큰돌쌓기는 품셈 **13-6** 이고 돌쌓기는 **13-4** 다 — **규격 축이 다르다.**
|
# 큰돌쌓기는 품셈 **13-6** 이고 돌쌓기는 **13-4** 다 — **규격 축이 다르다.**
|
||||||
# 돌쌓기는 **뒷길이**(35·45·55·60㎝), 큰돌쌓기는 **직경**(40~60·60~80·80~100㎝).
|
# 돌쌓기는 **뒷길이**(35·45·55·60㎝), 큰돌쌓기는 **직경**(40~60·60~80·80~100㎝).
|
||||||
@@ -126,6 +130,20 @@ EXPANDERS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _box_culvert():
|
||||||
|
"""BOX암거 전개 함수를 늦게 가져온다."""
|
||||||
|
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Box import box_culvert
|
||||||
|
|
||||||
|
return box_culvert
|
||||||
|
|
||||||
|
|
||||||
|
def _pavement():
|
||||||
|
"""콘크리트 포장 모듈을 늦게 가져온다."""
|
||||||
|
from B08_Quantity import B08_Quantity_Engine_UnitQuantity_Pavement
|
||||||
|
|
||||||
|
return B08_Quantity_Engine_UnitQuantity_Pavement
|
||||||
|
|
||||||
|
|
||||||
def _soil_guard():
|
def _soil_guard():
|
||||||
"""흙막이 사유 함수를 늦게 가져온다."""
|
"""흙막이 사유 함수를 늦게 가져온다."""
|
||||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import soil_guard
|
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import soil_guard
|
||||||
@@ -162,17 +180,29 @@ def _revetment():
|
|||||||
|
|
||||||
|
|
||||||
#: 전개식을 일부러 안 두는 종류 — 왜 안 두는지 사람이 읽게 적는다.
|
#: 전개식을 일부러 안 두는 종류 — 왜 안 두는지 사람이 읽게 적는다.
|
||||||
EXPANDER_WITHHELD: dict[str, str] = {}
|
EXPANDER_WITHHELD: dict[str, str] = {
|
||||||
|
# 2026-09-15 브레인 판정 ⑥ — 찾아봤으나 없음(「아직 안 만듦」과 다름).
|
||||||
|
"ford_bridge": (
|
||||||
|
"세월교 수량 근거 없음 — 법령·임도기술교본은 설치 조건만(그림 3-18 은 배치 모식도)"
|
||||||
|
" · 실무 원본에 세월교 시트·도면 없음 · 관은 배수관 줄로 따로 섬"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
#: 전개식이 **개소당·㎡당**으로 값을 내는 종류의 내역 단위와 수량(2026-09-14 구조물도 그림에서 잡음).
|
#: 전개식이 **개소당·㎡당**으로 값을 내는 종류의 내역 단위와 수량(2026-09-14 구조물도 그림에서 잡음).
|
||||||
#: ⚠ 안 적으면 「m · 연장」으로 읽혀 — 골막이·바닥막이(점 배치라 연장 0)는 **수량 0 이라 내역에 안 서고**
|
#: ⚠ 안 적으면 「m · 연장」으로 읽혀 — 골막이·바닥막이(점 배치라 연장 0)는 **수량 0 이라 내역에 안 서고**
|
||||||
#: 구조물도가 전 줄 「단위당 못 냄」, 떼흙막이는 개소당 값이 연장으로 나뉘어 **떼 0.139/m** 로 보였음.
|
#: 구조물도가 전 줄 「단위당 못 냄」, 떼흙막이는 개소당 값이 연장으로 나뉘어 **떼 0.139/m** 로 보였음.
|
||||||
EXPANDER_BILLING: dict[str, Any] = {
|
EXPANDER_BILLING: dict[str, Any] = {
|
||||||
"erosion_check": lambda options: ("개소", 1.0), # 정본 「골막이(찰)(치수조서연결)」 개소당
|
"erosion_check": lambda options, length: ("개소", 1.0), # 정본 「골막이(찰)」 개소당
|
||||||
"soil_guard": lambda options: ("개소", 1.0), # 정본 「떼흙막이」 개소당(평균 붙박이)
|
"soil_guard": lambda options, length: ("개소", 1.0), # 정본 「떼흙막이」 개소당(평균 붙박이)
|
||||||
"bed_sill": lambda options: ("㎡", _num(options.get("area_m2"), 0.0)), # 정본 돌붙임 ㎡당
|
"bed_sill": lambda options, length: ("㎡", _num(options.get("area_m2"), 0.0)), # 돌붙임 ㎡당
|
||||||
|
"box_culvert": lambda options, length: ("m", _num(options.get("length_m"), 0.0)), # 점 배치
|
||||||
|
"pavement_concrete": lambda options, length: _pavement().billing(length, options), # 면적 ㎡
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#: 연장을 **구간(끝 − 시작)** 에서 읽는 종류 — 포장은 물넘이로 나뉘면 길이 칸(10)이 조각마다
|
||||||
|
#: 그대로 복사돼 칸 값이 구간과 안 맞음(`B05_Profile_UI_Structures_Panel_Commit` 나누기).
|
||||||
|
SPAN_LENGTH_TYPES = frozenset({"pavement_concrete"})
|
||||||
|
|
||||||
|
|
||||||
#: 한 구조물이 **여러 내역 줄**을 낳는 자리. 배수관은 관 자체와 유입부 집수정이 따로 선다
|
#: 한 구조물이 **여러 내역 줄**을 낳는 자리. 배수관은 관 자체와 유입부 집수정이 따로 선다
|
||||||
#: (품셈도 관부설과 집수정을 다른 공종으로 둔다). 한 줄로 합치면 어느 쪽 물량인지 못 가른다.
|
#: (품셈도 관부설과 집수정을 다른 공종으로 둔다). 한 줄로 합치면 어느 쪽 물량인지 못 가른다.
|
||||||
@@ -272,6 +302,11 @@ def expand(
|
|||||||
start = _num(structure.get("start_m"))
|
start = _num(structure.get("start_m"))
|
||||||
end = _num(structure.get("end_m"))
|
end = _num(structure.get("end_m"))
|
||||||
length = _num(options.get("length_m")) or abs(end - start)
|
length = _num(options.get("length_m")) or abs(end - start)
|
||||||
|
if type_id in SPAN_LENGTH_TYPES and None not in (
|
||||||
|
structure.get("start_m"),
|
||||||
|
structure.get("end_m"),
|
||||||
|
):
|
||||||
|
length = abs(end - start)
|
||||||
height = _num(options.get("height_m"))
|
height = _num(options.get("height_m"))
|
||||||
# ⚠ 레지스트리 이름이 없으면 **코드값(`retaining_wall`)이 그대로 내역에 뜬다** —
|
# ⚠ 레지스트리 이름이 없으면 **코드값(`retaining_wall`)이 그대로 내역에 뜬다** —
|
||||||
# `_Wording.type_label` 이 이미 대비표를 들고 있으므로 그것을 쓴다(2026-09-09 감사).
|
# `_Wording.type_label` 이 이미 대비표를 들고 있으므로 그것을 쓴다(2026-09-09 감사).
|
||||||
@@ -324,10 +359,19 @@ def expand(
|
|||||||
result.notes.extend(notes)
|
result.notes.extend(notes)
|
||||||
billing = EXPANDER_BILLING.get(type_id)
|
billing = EXPANDER_BILLING.get(type_id)
|
||||||
if billing is not None and result.components:
|
if billing is not None and result.components:
|
||||||
result.billing_unit, result.billing_quantity = billing(options)
|
result.billing_unit, result.billing_quantity = billing(options, length)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _shoring_of(quantities: Iterable[StructureQuantity]) -> dict[str, Any]:
|
||||||
|
"""동바리 상태 — 전개식이 동바리 줄을 낸 구조물이 있으면 대상(2026-09-15 BOX암거)."""
|
||||||
|
status = shoring_status()
|
||||||
|
found = sorted({q.name for q in quantities if any(c.name == "동바리" for c in q.components)})
|
||||||
|
if found:
|
||||||
|
status.update(applicable=True, reason=f"슬래브 받침 동바리(12-20) — {' · '.join(found)}")
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
def verify_no_mix_components(quantities: Iterable[StructureQuantity]) -> list[str]:
|
def verify_no_mix_components(quantities: Iterable[StructureQuantity]) -> list[str]:
|
||||||
"""⚠ 배합 성분이 산출물에 섞이면 알린다 (㉢ 이중계상 방어).
|
"""⚠ 배합 성분이 산출물에 섞이면 알린다 (㉢ 이중계상 방어).
|
||||||
|
|
||||||
@@ -495,6 +539,21 @@ def _append_section_trench(
|
|||||||
quantity.components.extend(Component(**row) for row in rows)
|
quantity.components.extend(Component(**row) for row in rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_box_trench(
|
||||||
|
quantity: StructureQuantity, structure: dict[str, Any], thickness_m: float | None
|
||||||
|
) -> None:
|
||||||
|
"""BOX암거 구체 터파기(토공 축) — 기초잡석과 **같은 두께**로 든 것을 뺌(2026-09-15)."""
|
||||||
|
if quantity.type_id != "box_culvert" or not quantity.components:
|
||||||
|
return
|
||||||
|
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Box import box_trench
|
||||||
|
|
||||||
|
thickness = RUBBLE_BASE_THICKNESS_M if thickness_m is None else float(thickness_m)
|
||||||
|
found = box_trench(structure.get("options") or {}, max(thickness, 0.0))
|
||||||
|
if found is not None:
|
||||||
|
rows, quantity.trench_depth_m = found
|
||||||
|
quantity.components.extend(rows)
|
||||||
|
|
||||||
|
|
||||||
def build_table(
|
def build_table(
|
||||||
structures: Iterable[dict[str, Any]],
|
structures: Iterable[dict[str, Any]],
|
||||||
names: dict[str, str] | None = None,
|
names: dict[str, str] | None = None,
|
||||||
@@ -530,6 +589,7 @@ def build_table(
|
|||||||
if rubble is not None:
|
if rubble is not None:
|
||||||
quantity.components.append(rubble)
|
quantity.components.append(rubble)
|
||||||
_append_section_trench(quantity, item, observed, rubble_base_thickness_m)
|
_append_section_trench(quantity, item, observed, rubble_base_thickness_m)
|
||||||
|
_append_box_trench(quantity, item, rubble_base_thickness_m)
|
||||||
quantity.unconfirmed = str(item.get("unconfirmed") or "") or missing_height_reason(item)
|
quantity.unconfirmed = str(item.get("unconfirmed") or "") or missing_height_reason(item)
|
||||||
quantities.append(quantity)
|
quantities.append(quantity)
|
||||||
if use_templates:
|
if use_templates:
|
||||||
@@ -612,8 +672,8 @@ def build_table(
|
|||||||
"structures": payload_structures,
|
"structures": payload_structures,
|
||||||
"formwork_notes": formwork_notes,
|
"formwork_notes": formwork_notes,
|
||||||
"formwork_reuse_missing": formwork_missing,
|
"formwork_reuse_missing": formwork_missing,
|
||||||
# 동바리 — 대상이 없으면 0 이 아니라 「없음」이라고 말한다.
|
# 동바리 — 대상이 없으면 0 이 아니라 「없음」이라고 말한다(BOX암거 동바리 줄이 서면 대상).
|
||||||
"shoring": shoring_status(),
|
"shoring": _shoring_of(quantities),
|
||||||
# ⚠ 값을 바꾸는 설계 조건인데 우리 제원에 칸이 없는 것 — 화면에 드러낸다.
|
# ⚠ 값을 바꾸는 설계 조건인데 우리 제원에 칸이 없는 것 — 화면에 드러낸다.
|
||||||
# 「무엇을 정해야 하는지」만으로는 부족하고 **「정하면 얼마나 달라지는지」**까지.
|
# 「무엇을 정해야 하는지」만으로는 부족하고 **「정하면 얼마나 달라지는지」**까지.
|
||||||
"pending_choices": (observed.pending_choices or {}).get("items") or [],
|
"pending_choices": (observed.pending_choices or {}).get("items") or [],
|
||||||
|
|||||||
@@ -58,6 +58,15 @@ DESTINATION = {
|
|||||||
"유로폼": "unit_price", # 거푸집 계열(품셈 12-38) — 설치·해체 품
|
"유로폼": "unit_price", # 거푸집 계열(품셈 12-38) — 설치·해체 품
|
||||||
"면목": "material",
|
"면목": "material",
|
||||||
"떼": "material", # 사면 떼(자재총괄 extra)와 같은 자리 · 할증 10%(1-3-1)
|
"떼": "material", # 사면 떼(자재총괄 extra)와 같은 자리 · 할증 10%(1-3-1)
|
||||||
|
# ⭐ 2026-09-15 BOX암거 — 버림 옆면 합판 · 동바리(12-20)·비계(12-19)는 설치 품이라 묶음 조각으로.
|
||||||
|
"합판거푸집": "unit_price",
|
||||||
|
"동바리": "unit_price",
|
||||||
|
"비계": "unit_price",
|
||||||
|
# ⭐ 2026-09-15 콘크리트 포장 — 거푸집(12-8)·수축줄눈(12-7-1 절단)은 품 · 비닐·철망은 12-6 [주]② 재료 별도.
|
||||||
|
"포장거푸집": "unit_price",
|
||||||
|
"수축줄눈": "unit_price",
|
||||||
|
"비닐": "material",
|
||||||
|
"철망": "material",
|
||||||
}
|
}
|
||||||
|
|
||||||
#: ⛔ **갈 곳 기본값 금지**(명세 13장 「빠진 것이 조용해짐」) — 표에 없는 이름은 막고 사유를 냄.
|
#: ⛔ **갈 곳 기본값 금지**(명세 13장 「빠진 것이 조용해짐」) — 표에 없는 이름은 막고 사유를 냄.
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"""BOX암거 전개식 — **m당 × 연장** (2026-09-15 브레인 12장 D 판정 ①~⑤).
|
||||||
|
|
||||||
|
근거 둘 — 구조가 같다
|
||||||
|
① 산림과임업기술(임도) 5장 「나) 구조물도에 의한 수량산출(예)」 · 그림 5-2-70
|
||||||
|
Box(2.0×1.5×2련×6.03m) — 구체 = (외곽 − 유수구 + 헌치) × L · 기초 = 폭 × 0.1 × L ·
|
||||||
|
거푸집 = 외벽·유수구·받침판·귀면 · 동바리 = 유수구 단면 × L · 철근 「상세도에서」(값 없음)
|
||||||
|
② 실무 울진 기번3 `5. 구조도(기번3).xlsx` 「암거수량집계표(2×2)」 m당 — 레미콘 2.84 ·
|
||||||
|
버림 0.28 · 유로폼 11.132 · 합판6회 0.2 · 철근 SD40 H13 0.179 + H16 0.157t · 동바리 3.92 ·
|
||||||
|
비계 18.72
|
||||||
|
두께 제안값은 등록부 칸(예제 벽 0.3 · 판 0.25 · 헌치 0.2 · 버림 0.1 · KDS 300㎜ 병기).
|
||||||
|
⚠ 1련만 — 등록부에 련수 칸이 없음(예제 식은 련수로 늘어나나 그림·칸이 1련).
|
||||||
|
⚠ 철근은 **크기별 식이 없다** — 울진 2×2 관측값만 선다(판정 ②).
|
||||||
|
⚠ 비계 시종점 두 면은 **개소당 한 번** — 원문은 m당 칸에 넣어 연장을 곱하면 부풀어 오름.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Base import Component, _num, routed
|
||||||
|
|
||||||
|
#: 버림 폭 여유(m, 한쪽) — 예제 5.1 = 외곽 4.9 + 0.1×2 · 울진 2.8 = 외곽 2.6 + 0.1×2 (둘이 같음).
|
||||||
|
BLINDING_EXTRA_M = 0.1
|
||||||
|
#: 철근 관측값(kg/m) — 울진 기번3 「암거수량집계표(2×2)」 SD40 · 내공 2.0×2.0 · 벽·판 0.3.
|
||||||
|
REBAR_OBSERVED_2X2 = (("D13", 179.0), ("D16", 157.0))
|
||||||
|
REBAR_SIZE_ONLY = (
|
||||||
|
"철근 — 크기별 식 없음 · 관측값은 2×2 뿐(울진 기번3 구조도 SD40 H13 0.179 + H16 0.157t/m ·"
|
||||||
|
" 벽·판 0.3) — 이 크기는 철근이 안 섬(2026-09-15 브레인 판정 ②)"
|
||||||
|
)
|
||||||
|
SOURCES = (
|
||||||
|
"산식 = 산림과임업기술(임도) 5장 수량산출 예제(그림 5-2-70) · 실무 울진 기번3 구조도"
|
||||||
|
" 「암거수량집계표(2×2)」"
|
||||||
|
)
|
||||||
|
NOTE_FORMWORK = (
|
||||||
|
"거푸집 — 본체 유로폼(외벽 · 내벽 헌치 뺀 높이 · 상판 밑면 · 귀면 넷) · 버림 옆면 합판"
|
||||||
|
" (울진 2×2 모양 · 예제는 합판 3회)"
|
||||||
|
)
|
||||||
|
NOTE_SHORING_SCAFFOLD = (
|
||||||
|
"동바리(유수구 단면 − 헌치)·비계(외벽 양면 + 시종점 두 면)는 **실무 관측 모양**"
|
||||||
|
"(울진 2×2 「강재 3개월」) — 품셈 12-20 강관동바리 · 12-19 강관비계로 이음 ·"
|
||||||
|
" 시종점 두 면은 개소당 한 번(원문은 m당 칸)"
|
||||||
|
)
|
||||||
|
#: 비계는 공㎥(품셈 12-19 밑수) = 면 × 폭 — 울진 단위 칸 「공/m3」 · 시종점 식 ×1.00 은 연장일 수 없음.
|
||||||
|
SCAFFOLD_NOTE = "비계 폭 1.0m 관측(울진)"
|
||||||
|
NOTE_WING = "날개벽 수량 근거 없음(교본·실무 모두 · 판정 ⑤) — 날개벽 줄 안 섬"
|
||||||
|
NOTE_RUBBLE = (
|
||||||
|
"기초잡석 — 근거 둘(예제·울진 집계표)에는 잡석 줄이 없음 — 사용자 확정 3차 ② 로 세움"
|
||||||
|
"(버림이 선 구조물엔 잡석 · 옹벽과 같은 규칙)"
|
||||||
|
)
|
||||||
|
#: 구체 터파기 — 울진 「암거구체 토공(2×2)」: 밑폭 = 버림 폭 + 여유 0.5×2 · 옆면 1:0.5.
|
||||||
|
TRENCH_CLEARANCE_M = 0.5
|
||||||
|
TRENCH_SIDE_SLOPE = 0.5
|
||||||
|
NOTE_TRENCH_MISSING = (
|
||||||
|
"평균 터파기고 칸이 비어 구체 터파기·되메우기가 안 섬 — 칸을 적으면 토공으로 섬"
|
||||||
|
)
|
||||||
|
NOTE_TRENCH = (
|
||||||
|
"구체 터파기 — 밑폭·옆면은 울진 「암거구체 토공(2×2)」(여유 0.5 · 1:0.5)"
|
||||||
|
" · 깊이 = 평균 터파기고 칸 · 되메우기 = 터파기 − 든 것(기초잡석·버림·구체)"
|
||||||
|
" — 원문은 잔토에 구체 전체 · 되메우기 한쪽만이라 옹벽 식(터파기 − 든 것)으로 맞춤"
|
||||||
|
" · 토공집계로만 감(이중계상 방지)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def box_culvert(options: dict[str, Any]) -> tuple[list[Component], list[str]]:
|
||||||
|
"""BOX암거 한 개소 — (성분, 사유). 연장이 없으면 성분 없이 사유만."""
|
||||||
|
length = _num(options.get("length_m"))
|
||||||
|
if length <= 0:
|
||||||
|
return [], ["BOX암거 연장(계류 방향)이 없어 수량이 안 섬 — 연장 칸을 적을 것(기본값 없음)"]
|
||||||
|
width, height = _num(options.get("body_width_m")), _num(options.get("body_height_m"))
|
||||||
|
wall, slab = _num(options.get("wall_thickness_m")), _num(options.get("slab_thickness_m"))
|
||||||
|
haunch, blinding = _num(options.get("haunch_m")), _num(options.get("blinding_thickness_m"))
|
||||||
|
if min(width, height, wall, slab) <= 0:
|
||||||
|
return [], ["BOX암거 본체 폭·높이·두께 중 빈 칸이 있어 수량이 안 섬"]
|
||||||
|
|
||||||
|
outer_w, outer_h = width + 2 * wall, height + 2 * slab
|
||||||
|
chamfer = math.sqrt(2) * haunch
|
||||||
|
per_m: list[tuple[str, str, float, str, str]] = [
|
||||||
|
(
|
||||||
|
"콘크리트",
|
||||||
|
"㎥",
|
||||||
|
outer_w * outer_h - width * height + haunch**2 / 2 * 4,
|
||||||
|
f"{outer_w:g}×{outer_h:g} − {width:g}×{height:g} + {haunch:g}²÷2×4",
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"버림콘크리트",
|
||||||
|
"㎥",
|
||||||
|
(outer_w + 2 * BLINDING_EXTRA_M) * blinding,
|
||||||
|
f"({outer_w:g} + {BLINDING_EXTRA_M:g}×2) × {blinding:g}",
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"유로폼",
|
||||||
|
"㎡",
|
||||||
|
2 * outer_h + 2 * (height - 2 * haunch) + (width - 2 * haunch) + 4 * chamfer,
|
||||||
|
f"외벽 {outer_h:g}×2 + 내벽 ({height:g} − {haunch:g}×2)×2"
|
||||||
|
f" + 상판 밑 ({width:g} − {haunch:g}×2) + 귀면 √({haunch:g}²+{haunch:g}²)×4",
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
("합판거푸집", "㎡", 2 * blinding, f"버림 옆면 {blinding:g}×2", ""),
|
||||||
|
(
|
||||||
|
"동바리",
|
||||||
|
"㎥",
|
||||||
|
width * height - 2 * haunch**2,
|
||||||
|
f"{width:g}×{height:g} − {haunch:g}²÷2×4",
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
("비계", "㎥", 2 * outer_h, f"외벽 양면 {outer_h:g}×2 × 폭 1.0({SCAFFOLD_NOTE})", ""),
|
||||||
|
]
|
||||||
|
notes: list[str] = [SOURCES, NOTE_FORMWORK, NOTE_SHORING_SCAFFOLD, NOTE_WING, NOTE_RUBBLE]
|
||||||
|
if math.isclose(width, 2.0) and math.isclose(height, 2.0):
|
||||||
|
per_m += [
|
||||||
|
("이형철근", "kg", kg, "울진 기번3 구조도 2×2 관측(SD40)", size)
|
||||||
|
for size, kg in REBAR_OBSERVED_2X2
|
||||||
|
]
|
||||||
|
notes.append("철근은 관측값(울진 2×2 · 벽·판 0.3) — 두께를 바꿔도 따라 안 바뀜")
|
||||||
|
else:
|
||||||
|
notes.append(REBAR_SIZE_ONLY)
|
||||||
|
|
||||||
|
components = [
|
||||||
|
Component(
|
||||||
|
name,
|
||||||
|
unit,
|
||||||
|
amount * length,
|
||||||
|
destination,
|
||||||
|
f"{basis} = m당 {amount:.4g} × 연장 {length:g}m",
|
||||||
|
spec=spec,
|
||||||
|
)
|
||||||
|
for name, unit, amount, basis, spec in per_m
|
||||||
|
if (destination := routed(name, notes))
|
||||||
|
]
|
||||||
|
ends = 2 * outer_w * outer_h
|
||||||
|
for component in components:
|
||||||
|
if component.name == "비계":
|
||||||
|
component.amount += ends
|
||||||
|
component.basis += (
|
||||||
|
f" + 시종점 두 면 {outer_w:g}×{outer_h:g}×2 = {ends:.4g}(개소당 한 번)"
|
||||||
|
)
|
||||||
|
if _num(options.get("trench_depth_m")) <= 0:
|
||||||
|
notes.append(NOTE_TRENCH_MISSING)
|
||||||
|
return components, notes
|
||||||
|
|
||||||
|
|
||||||
|
def box_trench(
|
||||||
|
options: dict[str, Any], rubble_thickness_m: float
|
||||||
|
) -> tuple[list[Component], float] | None:
|
||||||
|
"""구체 터파기·되메우기·잔토(토공 축) — 평균 터파기고가 비면 `None`.
|
||||||
|
|
||||||
|
기초잡석 두께는 원단위표와 **같은 값**을 받음(`build_table` 의 두께) — 두 벌로 안 셈.
|
||||||
|
"""
|
||||||
|
depth, length = _num(options.get("trench_depth_m")), _num(options.get("length_m"))
|
||||||
|
outer_w = _num(options.get("body_width_m")) + 2 * _num(options.get("wall_thickness_m"))
|
||||||
|
outer_h = _num(options.get("body_height_m")) + 2 * _num(options.get("slab_thickness_m"))
|
||||||
|
blinding = _num(options.get("blinding_thickness_m"))
|
||||||
|
if depth <= 0 or length <= 0 or outer_w <= 0:
|
||||||
|
return None
|
||||||
|
base_w = outer_w + 2 * BLINDING_EXTRA_M
|
||||||
|
bottom = base_w + 2 * TRENCH_CLEARANCE_M
|
||||||
|
top = bottom + 2 * TRENCH_SIDE_SLOPE * depth
|
||||||
|
excavation = (bottom + top) / 2 * depth
|
||||||
|
body = min(max(depth - rubble_thickness_m - blinding, 0.0), outer_h)
|
||||||
|
filled = base_w * (rubble_thickness_m + blinding) + outer_w * body
|
||||||
|
notes: list[str] = []
|
||||||
|
rows = [
|
||||||
|
(
|
||||||
|
"터파기",
|
||||||
|
excavation,
|
||||||
|
f"(밑폭 {bottom:.2f}(버림 폭 {base_w:g} + 여유 {TRENCH_CLEARANCE_M:g}×2) + 윗폭"
|
||||||
|
f" {top:.2f}(1:{TRENCH_SIDE_SLOPE:g})) ÷ 2 × 깊이 {depth:g} · {NOTE_TRENCH}",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"되메우기",
|
||||||
|
excavation - filled,
|
||||||
|
f"터파기 − 든 것 {filled:.3f}(버림 폭 × (잡석 {rubble_thickness_m:g}"
|
||||||
|
f" + 버림 {blinding:g}) + 구체 폭 {outer_w:g} × {body:.2f})",
|
||||||
|
),
|
||||||
|
("잔토처리", filled, "터파기 − 되메우기"),
|
||||||
|
]
|
||||||
|
return [
|
||||||
|
Component(name, "㎥", amount * length, destination, f"{basis} × 연장 {length:g}m")
|
||||||
|
for name, amount, basis in rows
|
||||||
|
if (destination := routed(name, notes))
|
||||||
|
], depth
|
||||||
@@ -422,7 +422,7 @@ def stone_masonry(
|
|||||||
notes.append(
|
notes.append(
|
||||||
f"막자갈 뒷채움 폭이 정본(`04.구조도(기슭막이).xls`) 값 상 {backfill_top:g} · "
|
f"막자갈 뒷채움 폭이 정본(`04.구조도(기슭막이).xls`) 값 상 {backfill_top:g} · "
|
||||||
f"하 {backfill_bottom:g}m 붙박이입니다 — 구조물 제원에 뒷채움 폭 칸이 없습니다"
|
f"하 {backfill_bottom:g}m 붙박이입니다 — 구조물 제원에 뒷채움 폭 칸이 없습니다"
|
||||||
"(소광리는 같은 식에 0.30/0.60 을 씁니다)"
|
"(소광리는 같은 식에 0.30/0.60 을 씁니다) · 칸을 새로 두는 것은 B05 등록부 몫"
|
||||||
)
|
)
|
||||||
# ⚠ 근거 구간 밖은 **값이 서되 그 사실이 보여야 한다** — 값은 계속 나오므로 사유가
|
# ⚠ 근거 구간 밖은 **값이 서되 그 사실이 보여야 한다** — 값은 계속 나오므로 사유가
|
||||||
# 없으면 아무도 못 본다(2026-09-09 그물 침).
|
# 없으면 아무도 못 본다(2026-09-09 그물 침).
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""콘크리트 포장 전개식 — **면적 × 두께 · 거푸집 양면 · 수축줄눈** (2026-09-15 브레인 포장 둘).
|
||||||
|
|
||||||
|
근거
|
||||||
|
① 임도기술교본 3-2 기본설계 — 「포장 폭의 3.0m, 두께 0.2m를 기준으로 곡선부에는 확폭」
|
||||||
|
② 임도기술교본 부록 4-7 7-1 3.5.1 — 「수축줄눈의 간격은 4-6m를 기준」
|
||||||
|
③ 산림품셈 12-6 콘크리트 포장(인력) — 비닐·철망깔기·포설·양생 포함 · 거푸집·줄눈 제외 ·
|
||||||
|
[주]② 비닐·양생재·철망 재료 별도 · 12-7-1 포장절단 · 12-8 콘크리트 포장 거푸집(연장 m)
|
||||||
|
④ 실무 울진소광 `02-1-공종수량산출` 「포장및난간개거」 — 면적 = B × L · 거푸집(1면기준) = 2L ·
|
||||||
|
수축줄눈 = 내림(L÷6) × B · 「포장확폭」 면적 = 거리 × 확폭 · 신축줄눈 = 면적 ÷ 6
|
||||||
|
⭐ 폭·확폭·두께는 **B06 이 이미 앎** — 빈 칸만 B06 에서 잇고 칸이 이김(판정 ①④ · 한 값 원칙).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Base import Component, _num, routed
|
||||||
|
|
||||||
|
SOURCES = (
|
||||||
|
"산식 = 임도기술교본 3-2(폭·두께·곡선부 확폭) · 부록 4-7 7-1(수축줄눈 4-6m) · 실무 울진소광"
|
||||||
|
" 「포장및난간개거」·「포장확폭」(면적 B×L · 거푸집 2L · 줄눈 내림(L÷간격)×B"
|
||||||
|
" · 확폭 줄눈 면적÷간격)"
|
||||||
|
)
|
||||||
|
NOTE_JOINT = (
|
||||||
|
"수축줄눈 = 12-7-1 포장절단(m)으로 이음 — 12-7-2 줄눈설치는 안 씀 — 소광 실무가 컷팅"
|
||||||
|
"(「수축줄눈(컷팅)」) · 줄눈재를 넣는 공법이면 12-7-2 로 물을 것"
|
||||||
|
)
|
||||||
|
NOTE_FORM = (
|
||||||
|
"포장거푸집 = 양쪽 가장자리 2 × 연장(소광 「1면기준」) · 12-8 이 강재 3m·핀폴·20회를 품에 둠"
|
||||||
|
)
|
||||||
|
SHEET_UNCHOSEN = (
|
||||||
|
"비닐(깔기·양생)을 안 고름 — 품셈 12-6 [주]② 「양생에 필요한 재료비(비닐, 양생재 등)는 별도"
|
||||||
|
" 계상」이라 설계자가 고를 것(기본값 없음)"
|
||||||
|
)
|
||||||
|
MESH_UNCHOSEN = (
|
||||||
|
"철망을 안 고름 — 품셈 12-6 [주]② 「철망재료비는 별도 계상」 · 교본 부록 7-1 3.4 「설계도서에"
|
||||||
|
" 따라」 · 실무 소광은 「철망포함」 관측 — 설계자가 고를 것(기본값 없음)"
|
||||||
|
)
|
||||||
|
MISSING = "콘크리트 포장 {what} — 칸이 비고 B06 에서도 못 읽어 수량이 안 섬 · 칸에 적을 것"
|
||||||
|
JOINT = "joint_spacing_m"
|
||||||
|
|
||||||
|
|
||||||
|
def _stations(designs: list[dict[str, Any]] | None) -> list[tuple[float, dict[str, Any]]]:
|
||||||
|
rows = [
|
||||||
|
(_num(item.get("chainage_m")), item.get("design"))
|
||||||
|
for item in designs or ()
|
||||||
|
if isinstance(item, dict) and isinstance(item.get("design"), dict)
|
||||||
|
]
|
||||||
|
return sorted(rows, key=lambda row: row[0])
|
||||||
|
|
||||||
|
|
||||||
|
def _widening(design: dict[str, Any]) -> float:
|
||||||
|
return _num(design.get("widening_left_m")) + _num(design.get("widening_right_m"))
|
||||||
|
|
||||||
|
|
||||||
|
def _widening_at(stations: list[tuple[float, dict[str, Any]]], at: float) -> float:
|
||||||
|
"""그 자리의 확폭 — 앞뒤 측점 사이 직선 보간 · 끝 밖이면 가장 가까운 측점."""
|
||||||
|
if at <= stations[0][0]:
|
||||||
|
return _widening(stations[0][1])
|
||||||
|
for (s0, d0), (s1, d1) in zip(stations, stations[1:]):
|
||||||
|
if s0 <= at <= s1:
|
||||||
|
t = 0.0 if s1 == s0 else (at - s0) / (s1 - s0)
|
||||||
|
return _widening(d0) + (_widening(d1) - _widening(d0)) * t
|
||||||
|
return _widening(stations[-1][1])
|
||||||
|
|
||||||
|
|
||||||
|
def pavement_from_designs(
|
||||||
|
structure: dict[str, Any], designs: list[dict[str, Any]] | None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""빈 칸(폭·확폭·두께)을 B06 구간 측점에서 채운 **새** 구조물 — 칸이 차 있으면 그대로."""
|
||||||
|
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
|
||||||
|
|
||||||
|
options = dict(structure.get("options") or {})
|
||||||
|
notes = list(structure.get("notes") or [])
|
||||||
|
# 상세 칸은 배치 폼에 없어 제안값이 저장에 안 들어감 — 등록부 한 벌에서 채움(값 두 벌 금지).
|
||||||
|
spacing = next(o for o in structure_type_map()["pavement_concrete"].options if o.key == JOINT)
|
||||||
|
if options.get(JOINT) in (None, "") and spacing.default is not None:
|
||||||
|
options[JOINT] = spacing.default
|
||||||
|
notes.append(f"수축줄눈 간격 {spacing.default:g}m — {spacing.default_basis}")
|
||||||
|
stations = _stations(designs)
|
||||||
|
start, end = sorted((_num(structure.get("start_m")), _num(structure.get("end_m"))))
|
||||||
|
inside = [design for chainage, design in stations if start <= chainage <= end]
|
||||||
|
if stations and not inside:
|
||||||
|
inside = [min(stations, key=lambda row: abs(row[0] - (start + end) / 2))[1]]
|
||||||
|
if options.get("width_m") in (None, "") and inside:
|
||||||
|
widths = {round(_num(d.get("carriageway_standard_width_m")), 3) for d in inside} - {0.0}
|
||||||
|
if len(widths) == 1:
|
||||||
|
options["width_m"] = widths.pop()
|
||||||
|
notes.append(f"포장 폭 {options['width_m']:g}m — B06 노폭(구간 측점 {len(inside)}곳)")
|
||||||
|
elif widths:
|
||||||
|
notes.append(f"포장 폭 — 구간 측점의 B06 노폭이 갈림({sorted(widths)}) · 칸에 적을 것")
|
||||||
|
if options.get("thickness_cm") in (None, "") and inside:
|
||||||
|
thick = {round(_num(d.get("pavement_thickness_m")), 4) for d in inside} - {0.0}
|
||||||
|
if len(thick) == 1:
|
||||||
|
options["thickness_cm"] = round(thick.pop() * 100, 2)
|
||||||
|
notes.append(f"포장 두께 {options['thickness_cm']:g}㎝ — B06 포장층 두께")
|
||||||
|
if options.get("widening_area_m2") in (None, "") and stations and end > start:
|
||||||
|
cuts = [start, *[s for s, _d in stations if start < s < end], end]
|
||||||
|
area = sum(
|
||||||
|
(_widening_at(stations, a) + _widening_at(stations, b)) / 2 * (b - a)
|
||||||
|
for a, b in zip(cuts, cuts[1:])
|
||||||
|
)
|
||||||
|
options["widening_area_m2"] = round(area, 4)
|
||||||
|
notes.append(f"확폭 면적 {area:.2f}㎡ — B06 곡선부 확폭 × 측점 사이 거리(평균)")
|
||||||
|
return {**structure, "options": options, "notes": notes}
|
||||||
|
|
||||||
|
|
||||||
|
def concrete_pavement(length: float, options: dict[str, Any]) -> tuple[list[Component], list[str]]:
|
||||||
|
"""콘크리트 포장 한 구간 — (성분, 사유). 면적 밑수가 없으면 성분 없이 사유만."""
|
||||||
|
width = _num(options.get("width_m"))
|
||||||
|
thickness = _num(options.get("thickness_cm")) / 100
|
||||||
|
widening = options.get("widening_area_m2")
|
||||||
|
missing = [
|
||||||
|
what
|
||||||
|
for what, value in (
|
||||||
|
("연장", length),
|
||||||
|
("폭", width),
|
||||||
|
("두께", thickness),
|
||||||
|
("확폭 면적", 1.0 if isinstance(widening, (int, float)) else 0.0),
|
||||||
|
)
|
||||||
|
if value <= 0
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
return [], [MISSING.format(what="·".join(missing))]
|
||||||
|
spacing = _num(options.get("joint_spacing_m"))
|
||||||
|
widening = float(widening)
|
||||||
|
area = width * length + widening
|
||||||
|
rows: list[tuple[str, str, float, str]] = [
|
||||||
|
(
|
||||||
|
"콘크리트",
|
||||||
|
"㎥",
|
||||||
|
area * thickness,
|
||||||
|
f"(폭 {width:g} × 연장 {length:g} + 확폭 {widening:g}) × 두께 {thickness:g}",
|
||||||
|
),
|
||||||
|
("포장거푸집", "m", 2 * length, f"양쪽 2 × 연장 {length:g}"),
|
||||||
|
]
|
||||||
|
notes = [SOURCES, NOTE_FORM]
|
||||||
|
if spacing > 0:
|
||||||
|
joints = math.floor(length / spacing) * width + widening / spacing
|
||||||
|
rows.append(
|
||||||
|
(
|
||||||
|
"수축줄눈",
|
||||||
|
"m",
|
||||||
|
joints,
|
||||||
|
f"내림({length:g} ÷ {spacing:g}) × 폭 {width:g} + 확폭 {widening:g} ÷ {spacing:g}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
notes.append(NOTE_JOINT)
|
||||||
|
for key, name, unchosen in (
|
||||||
|
("separation_sheet", "비닐", SHEET_UNCHOSEN),
|
||||||
|
("wire_mesh", "철망", MESH_UNCHOSEN),
|
||||||
|
):
|
||||||
|
choice = options.get(key)
|
||||||
|
if choice == "있음":
|
||||||
|
rows.append((name, "㎡", area, f"포장 면적 {area:.4g}"))
|
||||||
|
elif choice != "없음":
|
||||||
|
notes.append(unchosen)
|
||||||
|
components = [
|
||||||
|
Component(name, unit, amount, destination, basis)
|
||||||
|
for name, unit, amount, basis in rows
|
||||||
|
if (destination := routed(name, notes))
|
||||||
|
]
|
||||||
|
return components, notes
|
||||||
|
|
||||||
|
|
||||||
|
def billing(length: float, options: dict[str, Any]) -> tuple[str, float]:
|
||||||
|
"""내역 줄 — 포장 면적 ㎡(소광 「콘크리트포장 T=20 ㎡」)."""
|
||||||
|
widening = options.get("widening_area_m2")
|
||||||
|
extra = float(widening) if isinstance(widening, (int, float)) else 0.0
|
||||||
|
return "㎡", _num(options.get("width_m")) * length + extra
|
||||||
@@ -34,13 +34,25 @@ from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import (
|
|||||||
FACE_DRESSING_CUT_CLASSES,
|
FACE_DRESSING_CUT_CLASSES,
|
||||||
FACE_DRESSING_FILL_CLASSES,
|
FACE_DRESSING_FILL_CLASSES,
|
||||||
FACE_DRESSING_FILL_SUGGESTED,
|
FACE_DRESSING_FILL_SUGGESTED,
|
||||||
|
ROOT_REMOVAL_EXCAVATOR_SIZES,
|
||||||
|
ROOT_REMOVAL_EXCAVATOR_SUGGESTED,
|
||||||
|
SEED_SPRAY_GROUNDS,
|
||||||
SummaryInput,
|
SummaryInput,
|
||||||
)
|
)
|
||||||
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table as build_summary_table
|
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_EarthworkTable import StationArea, build_table
|
||||||
from B08_Quantity.B08_Quantity_Engine_Handoff import load_mapping
|
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 build_table as build_haul_table
|
||||||
from B08_Quantity.B08_Quantity_Engine_HaulSummary import check_against_plan, summary_input_rows
|
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_Preparation import build_table as build_preparation_table
|
||||||
from B08_Quantity.B08_Quantity_Engine_SlopeArea import build_table as build_slope_table
|
from B08_Quantity.B08_Quantity_Engine_SlopeArea import build_table as build_slope_table
|
||||||
from B08_Quantity.B08_Quantity_Engine_SlopeLength import road_surface_area, station_slopes
|
from B08_Quantity.B08_Quantity_Engine_SlopeLength import road_surface_area, station_slopes
|
||||||
@@ -51,10 +63,11 @@ from common_util.common_util_project_settings import (
|
|||||||
application_ratio,
|
application_ratio,
|
||||||
concrete_placing_method,
|
concrete_placing_method,
|
||||||
earthwork_conversion_choices,
|
earthwork_conversion_choices,
|
||||||
earthwork_conversion_factors,
|
mixed_conversion_factors,
|
||||||
haul_limit_choice,
|
haul_limit_choice,
|
||||||
quantity_settings,
|
quantity_settings,
|
||||||
rock_classes,
|
rock_classes,
|
||||||
|
rock_method,
|
||||||
save_section,
|
save_section,
|
||||||
topsoil_target,
|
topsoil_target,
|
||||||
)
|
)
|
||||||
@@ -93,7 +106,8 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
|
|||||||
)
|
)
|
||||||
# ⚠ 설정을 **먼저** 읽는다 — 토량환산계수를 프로젝트가 골랐으면 표가 그 값으로 서야 한다.
|
# ⚠ 설정을 **먼저** 읽는다 — 토량환산계수를 프로젝트가 골랐으면 표가 그 값으로 서야 한다.
|
||||||
settings, project_root = await _project_settings(project_id)
|
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 = build_table(_stations(designs), factors)
|
||||||
# 화면이 「무엇을 골랐나 · 품셈 범위 안인가」를 보이는 데 쓴다. 계산에는 안 들어간다.
|
# 화면이 「무엇을 골랐나 · 품셈 범위 안인가」를 보이는 데 쓴다. 계산에는 안 들어간다.
|
||||||
table["conversion_factor_choices"] = earthwork_conversion_choices(settings)
|
table["conversion_factor_choices"] = earthwork_conversion_choices(settings)
|
||||||
@@ -112,10 +126,15 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
|
|||||||
|
|
||||||
plan = await _stored_haul_plan(project_id, route_id)
|
plan = await _stored_haul_plan(project_id, route_id)
|
||||||
haul = build_haul_table(plan, factors)
|
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`)이고 없으면 막힌다.
|
# ⚠ 거리는 품셈이 정하지 않는다 — 설계 입력(`spoil_site_distance_m`)이고 없으면 막힌다.
|
||||||
haul["spoil"] = _spoil_of(plan, settings, _spoil_sites(designs))
|
haul["spoil"] = _spoil_of(plan, settings, _spoil_sites(designs))
|
||||||
|
haul["borrow"] = borrow_of(plan) # 토취(반입토) — 수량만 · 금액은 사유(브레인 ①)
|
||||||
# 배수관 연장 — B06 이 측점 `design.pipe_length_m` 에 남긴 값. **여기서 짓지 않는다.**
|
# 배수관 연장 — B06 이 측점 `design.pipe_length_m` 에 남긴 값. **여기서 짓지 않는다.**
|
||||||
# 인계가 관 줄을 세울 때 쓴다. 단면을 두 번 읽지 않으려고 이 응답에 실어 보낸다.
|
# 인계가 관 줄을 세울 때 쓴다. 단면을 두 번 읽지 않으려고 이 응답에 실어 보낸다.
|
||||||
table["pipe_lengths"] = [
|
table["pipe_lengths"] = [
|
||||||
@@ -148,6 +167,8 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
|
|||||||
earthwork_totals=table.get("totals") or {},
|
earthwork_totals=table.get("totals") or {},
|
||||||
slope_totals=slope.get("totals") or {},
|
slope_totals=slope.get("totals") or {},
|
||||||
haul_rows=summary_input_rows(haul),
|
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_classes=rock_classes(settings),
|
||||||
rock_ratios_pct=settings.get("rock_ratios_pct") or {},
|
rock_ratios_pct=settings.get("rock_ratios_pct") or {},
|
||||||
application_ratios={
|
application_ratios={
|
||||||
@@ -173,6 +194,16 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
|
|||||||
"basis": FACE_DRESSING_FILL_SUGGESTED[1],
|
"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)
|
structures = await _route_structures(project_id)
|
||||||
table["preparation"] = build_preparation_table(
|
table["preparation"] = build_preparation_table(
|
||||||
@@ -227,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]:
|
def _compacted_factor(settings: dict[str, Any]) -> dict[str, float]:
|
||||||
"""갈래 칸 ↔ 다짐 환산계수 `C` — 프로젝트가 고른 값이 있으면 그것이 선다."""
|
"""갈래 칸 ↔ 다짐 환산계수 `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()}
|
return {key: float(factors[kind]["compacted"]) for key, kind in _GROUND_KIND_OF.items()}
|
||||||
|
|
||||||
|
|
||||||
@@ -478,6 +509,10 @@ class QuantitySettingsBody(BaseModel):
|
|||||||
stand_volume_class: str | None = None
|
stand_volume_class: str | None = None
|
||||||
# 면고르기 갈래 — 절토면 토질 · 성토면 시공·토질(9-19-1 원문 표). `""` 는 「안 정함」.
|
# 면고르기 갈래 — 절토면 토질 · 성토면 시공·토질(9-19-1 원문 표). `""` 는 「안 정함」.
|
||||||
face_dressing_cut_class: str | None = None
|
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
|
face_dressing_fill_class: str | None = None
|
||||||
# 면고르기 면적 덮어쓰기(㎡) — `None` 은 파종 면적을 그대로(2026-09-14 판정 Ⓐ).
|
# 면고르기 면적 덮어쓰기(㎡) — `None` 은 파종 면적을 그대로(2026-09-14 판정 Ⓐ).
|
||||||
face_dressing_fill_area_m2: float | None = None
|
face_dressing_fill_area_m2: float | None = None
|
||||||
@@ -556,6 +591,8 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
|
|||||||
for key, choices in (
|
for key, choices in (
|
||||||
("face_dressing_cut_class", FACE_DRESSING_CUT_CLASSES),
|
("face_dressing_cut_class", FACE_DRESSING_CUT_CLASSES),
|
||||||
("face_dressing_fill_class", FACE_DRESSING_FILL_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:
|
if key in values and values[key] not in choices:
|
||||||
values[key] = "" # 선택지 밖·빈 값은 「안 정함」 — 가까운 갈래로 안 고침
|
values[key] = "" # 선택지 밖·빈 값은 「안 정함」 — 가까운 갈래로 안 고침
|
||||||
@@ -600,6 +637,7 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
|
|||||||
for name, method in values["rock_methods"].items()
|
for name, method in values["rock_methods"].items()
|
||||||
if method in ROCK_METHODS
|
if method in ROCK_METHODS
|
||||||
}
|
}
|
||||||
|
before = {key: quantity_settings(root).get(key) for key in HAUL_PLAN_KEYS}
|
||||||
try:
|
try:
|
||||||
# ⚠ 고른 값을 **되돌릴 수 있어야** 하는 칸은 통째로 갈아 끼운다 — 병합이면
|
# ⚠ 고른 값을 **되돌릴 수 있어야** 하는 칸은 통째로 갈아 끼운다 — 병합이면
|
||||||
# 「안 정함」으로 되돌아가지 않는다(2026-09-07 화면에서 걸린 자리).
|
# 「안 정함」으로 되돌아가지 않는다(2026-09-07 화면에서 걸린 자리).
|
||||||
@@ -627,7 +665,16 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
|
|||||||
status_code=500,
|
status_code=500,
|
||||||
content={"status": "error", "message": "산출 조건을 저장하지 못했습니다."},
|
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(
|
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
|
||||||
@@ -47,6 +47,7 @@ from common_util.common_util_project_settings import (
|
|||||||
)
|
)
|
||||||
from B08_Quantity.B08_Quantity_Engine_HaulInputs import haul_inputs
|
from B08_Quantity.B08_Quantity_Engine_HaulInputs import haul_inputs
|
||||||
from B08_Quantity.B08_Quantity_Engine_Preparation import frame_material_rows
|
from B08_Quantity.B08_Quantity_Engine_Preparation import frame_material_rows
|
||||||
|
from B08_Quantity.B08_Quantity_Engine_Preparation_Ancillary import ancillary_material_rows
|
||||||
from common_util.common_util_storage import resolve_stored_project_path
|
from common_util.common_util_storage import resolve_stored_project_path
|
||||||
from common_util.common_util_structure_lengths import structure_lengths
|
from common_util.common_util_structure_lengths import structure_lengths
|
||||||
from config.config_db import run_with_connection
|
from config.config_db import run_with_connection
|
||||||
@@ -96,6 +97,11 @@ def _collect_structures(
|
|||||||
if definition.reference_only:
|
if definition.reference_only:
|
||||||
skipped.append(f"{definition.name}: 전문 상세설계 대상 — 배치까지만")
|
skipped.append(f"{definition.name}: 전문 상세설계 대상 — 배치까지만")
|
||||||
continue
|
continue
|
||||||
|
if type_id == "pavement_concrete":
|
||||||
|
# 폭·확폭·두께는 B06 이 앎 — 빈 칸만 이음(2026-09-15 브레인 포장 둘 ①④).
|
||||||
|
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Pavement import pavement_from_designs
|
||||||
|
|
||||||
|
payload = pavement_from_designs(payload, designs)
|
||||||
if definition.group == "B":
|
if definition.group == "B":
|
||||||
# ⚠ B군(종단배수)은 **연장표**로 간다 — `common_util_structure_lengths` 가
|
# ⚠ B군(종단배수)은 **연장표**로 간다 — `common_util_structure_lengths` 가
|
||||||
# 겹친 구간을 합쳐 주기 때문이다. 구조물별로 세면 겹친 구간을 두 번 센다.
|
# 겹친 구간을 합쳐 주기 때문이다. 구조물별로 세면 겹친 구간을 두 번 센다.
|
||||||
@@ -172,7 +178,9 @@ def material_table_for(
|
|||||||
surcharge_overrides=settings.get("material_surcharge") or {},
|
surcharge_overrides=settings.get("material_surcharge") or {},
|
||||||
# 콘크리트 할증은 **레미콘일 때만** 붙는다 — 방식이 이름을 가른다(확정 3차 ⑥).
|
# 콘크리트 할증은 **레미콘일 때만** 붙는다 — 방식이 이름을 가른다(확정 3차 ⑥).
|
||||||
concrete_placing_method=settings.get("concrete_placing_method"),
|
concrete_placing_method=settings.get("concrete_placing_method"),
|
||||||
extra_materials=frame_material_rows(frame_rows, settings.get("frame_material") or {}),
|
extra_materials=frame_material_rows(frame_rows, settings.get("frame_material") or {})
|
||||||
|
# 공종 없는 부대시설 개소 — 「자재 단가」 탭에 단가를 넣는 통로(2026-09-14 브레인 판정).
|
||||||
|
+ ancillary_material_rows((preparation_table or {}).get("rows") or []),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -497,6 +505,10 @@ async def get_handoff(project_id: UUID) -> JSONResponse:
|
|||||||
# 면고르기 갈래 — 절토면 토질 · 성토면 시공·토질. 비면 그 줄이 입력 사유로 막힘.
|
# 면고르기 갈래 — 절토면 토질 · 성토면 시공·토질. 비면 그 줄이 입력 사유로 막힘.
|
||||||
face_dressing_cut_class=settings.get("face_dressing_cut_class") or None,
|
face_dressing_cut_class=settings.get("face_dressing_cut_class") or None,
|
||||||
face_dressing_fill_class=settings.get("face_dressing_fill_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장 ②).
|
# 구조물도 양식 일위대가로 셀 장 — 그 구조물은 호표 `AX-ST` 줄 하나로(PLAN 6장 ②).
|
||||||
priced_sheets=_priced_sheets(project_root, unit_table, modes, settings),
|
priced_sheets=_priced_sheets(project_root, unit_table, modes, settings),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,15 +6,18 @@
|
|||||||
② [넣기] 같은 파일 + 고른 호표 차례 + 우리 구조물 종류 → 서버가 **파일을 다시 읽어** 개인 단에 씀.
|
② [넣기] 같은 파일 + 고른 호표 차례 + 우리 구조물 종류 → 서버가 **파일을 다시 읽어** 개인 단에 씀.
|
||||||
⚠ 브라우저가 보낸 줄을 받아 적지 않음(CLAUDE.md 5장) · 개인 단만(판정 Ⓗ) · 종류당 하나라 같은 종류 내 것은
|
⚠ 브라우저가 보낸 줄을 받아 적지 않음(CLAUDE.md 5장) · 개인 단만(판정 Ⓗ) · 종류당 하나라 같은 종류 내 것은
|
||||||
덮어씀(판정 Ⓐ) · 종류는 사용자가 고름 — 이름으로 자동으로 안 붙임(판정 Ⓒ).
|
덮어씀(판정 Ⓐ) · 종류는 사용자가 고름 — 이름으로 자동으로 안 붙임(판정 Ⓒ).
|
||||||
|
③ [복제해서 내 것으로](PLAN 4장) — 기본·회사 단 항목을 개인 단에 베낌. 프로젝트 작업본은 안 바꿈.
|
||||||
|
(구조물도 라우터가 700줄에 닿아 개인 단으로 넣는 창구를 이 파일에 모음.)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import io
|
import io
|
||||||
from typing import Any
|
from typing import Any, Literal
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, Form, UploadFile
|
from fastapi import APIRouter, Depends, File, Form, UploadFile
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
from common_util.common_util_auth import verify_session
|
from common_util.common_util_auth import verify_session
|
||||||
|
|
||||||
@@ -110,3 +113,107 @@ async def save_stmate_recipe(
|
|||||||
"note": item["note"],
|
"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 ""})
|
||||||
|
|||||||
@@ -316,6 +316,7 @@ async def get_structure_library(
|
|||||||
"status": "success",
|
"status": "success",
|
||||||
"items": await asyncio.to_thread(list_items, dirs, type_id),
|
"items": await asyncio.to_thread(list_items, dirs, type_id),
|
||||||
"current": {"code": current.get("code"), "imported_from": current.get("imported_from")},
|
"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},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -326,7 +327,7 @@ class LibraryImportRequest(BaseModel):
|
|||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
type_id: str = Field(min_length=1, max_length=100)
|
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}$")
|
code: str = Field(pattern=r"^AX-ST-[0-9a-f]{8}$")
|
||||||
|
|
||||||
|
|
||||||
@@ -394,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):
|
class LibrarySaveRequest(BaseModel):
|
||||||
"""[내 라이브러리에 저장] — 어느 장의 양식을 쓸지."""
|
"""[내 라이브러리에 저장]·[발행] — 어느 장의 양식을 어느 단에 쓸지(같은 모양)."""
|
||||||
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
sheet_key: str
|
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/personal")
|
||||||
|
@router.put("/{project_id}/quantity/structure-sheets/library/publish")
|
||||||
async def put_structure_library_personal(
|
async def put_structure_library_personal(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
payload: LibrarySaveRequest,
|
payload: LibrarySaveRequest,
|
||||||
@@ -412,17 +431,18 @@ async def put_structure_library_personal(
|
|||||||
|
|
||||||
⚠ 프로젝트 작업본은 안 바꿈 — 반대 방향(작업본 → 개인 단)이라(판정 Ⓑ).
|
⚠ 프로젝트 작업본은 안 바꿈 — 반대 방향(작업본 → 개인 단)이라(판정 Ⓑ).
|
||||||
"""
|
"""
|
||||||
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import (
|
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import project_templates, save_personal
|
||||||
project_templates,
|
|
||||||
save_personal,
|
|
||||||
)
|
|
||||||
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import OVERRIDES_KEY, template_of
|
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import OVERRIDES_KEY, template_of
|
||||||
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import ROWS_KEY
|
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import ROWS_KEY
|
||||||
from common_util.common_util_project_settings import quantity_settings
|
from common_util.common_util_project_settings import quantity_settings
|
||||||
|
|
||||||
folder = _personal_dir(session)
|
folder = _publish_dir(session, payload.tier)
|
||||||
if folder is None:
|
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)
|
project_root = await _project_root(project_id)
|
||||||
if project_root is None:
|
if project_root is None:
|
||||||
return _not_found()
|
return _not_found()
|
||||||
@@ -439,7 +459,8 @@ async def put_structure_library_personal(
|
|||||||
overrides = (settings.get(OVERRIDES_KEY) or {}).get(type_id)
|
overrides = (settings.get(OVERRIDES_KEY) or {}).get(type_id)
|
||||||
# ⛔ 수동 단가(`MANUAL_KEY`)는 안 넘김 — 프로젝트의 값(브레인 판정).
|
# ⛔ 수동 단가(`MANUAL_KEY`)는 안 넘김 — 프로젝트의 값(브레인 판정).
|
||||||
rows = (settings.get(ROWS_KEY) or {}).get(type_id)
|
rows = (settings.get(ROWS_KEY) or {}).get(type_id)
|
||||||
code = await asyncio.to_thread(save_personal, folder, template, overrides, rows)
|
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 {})})
|
return JSONResponse(content={"status": "success", "code": code, "edited": len(overrides or {})})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -100,6 +100,10 @@ export interface QuantitySettings {
|
|||||||
ancillary_counts?: Record<string, number | null>;
|
ancillary_counts?: Record<string, number | null>;
|
||||||
/** 임목축적 등급 — `"소림"`·`"중림"`·`"밀림"`(품셈 9-21 [주]①). 본수가 아니라 축적이다. */
|
/** 임목축적 등급 — `"소림"`·`"중림"`·`"밀림"`(품셈 9-21 [주]①). 본수가 아니라 축적이다. */
|
||||||
stand_volume_class?: string | null;
|
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_cut_class?: string | null;
|
||||||
face_dressing_fill_class?: string | null;
|
face_dressing_fill_class?: string | null;
|
||||||
@@ -162,6 +166,13 @@ export interface EarthworkTable {
|
|||||||
conversion_factor_choices?: Record<string, ConversionFactorChoice>;
|
conversion_factor_choices?: Record<string, ConversionFactorChoice>;
|
||||||
/** 면고르기 갈래 선택지 — 품셈 9-19-1 원문 표 두 벌(서버 한 곳). 화면은 그대로 보임. */
|
/** 면고르기 갈래 선택지 — 품셈 9-19-1 원문 표 두 벌(서버 한 곳). 화면은 그대로 보임. */
|
||||||
face_dressing_choices?: { cut: string[]; fill: string[] };
|
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[];
|
conversion_factor_pumsem_ranges?: PumsemRange[];
|
||||||
/** 도쟈 한계거리 — 지금 값·기본값·근거(서버가 정본). 종무대 20 m 는 규정이라 값만 보인다. */
|
/** 도쟈 한계거리 — 지금 값·기본값·근거(서버가 정본). 종무대 20 m 는 규정이라 값만 보인다. */
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ import { renderStructureSheets } from "./B08_Quantity_UI_StructureSheet";
|
|||||||
import { renderStructureSummary } from "./B08_Quantity_UI_StructureSummary";
|
import { renderStructureSummary } from "./B08_Quantity_UI_StructureSummary";
|
||||||
import { appendTreeWasteFields } from "./B08_Quantity_UI_Side_TreeWaste";
|
import { appendTreeWasteFields } from "./B08_Quantity_UI_Side_TreeWaste";
|
||||||
import { appendFaceDressingFields } from "./B08_Quantity_UI_Side_FaceDressing";
|
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";
|
import { appendBenchCutFields } from "./B08_Quantity_UI_Side_BenchCut";
|
||||||
|
|
||||||
/** locale 헬퍼 */
|
/** locale 헬퍼 */
|
||||||
@@ -109,6 +111,8 @@ async function saveQuantitySettings(projectId: string, draft: DraftSettings): Pr
|
|||||||
// `""` 는 기본(노면 + 절토)으로 되돌림 — 서버가 None 으로 둔다.
|
// `""` 는 기본(노면 + 절토)으로 되돌림 — 서버가 None 으로 둔다.
|
||||||
topsoil_target: draft.topsoil_target,
|
topsoil_target: draft.topsoil_target,
|
||||||
stand_volume_class: draft.stand_volume_class,
|
stand_volume_class: draft.stand_volume_class,
|
||||||
|
root_removal_excavator_m3: draft.root_removal_excavator_m3,
|
||||||
|
seed_spray_ground: draft.seed_spray_ground,
|
||||||
// 면고르기 — 갈래 `""`·면적 `null` 도 그대로(「안 정함」·「파종 면적 그대로」로 되돌리는 길).
|
// 면고르기 — 갈래 `""`·면적 `null` 도 그대로(「안 정함」·「파종 면적 그대로」로 되돌리는 길).
|
||||||
face_dressing_cut_class: draft.face_dressing_cut_class,
|
face_dressing_cut_class: draft.face_dressing_cut_class,
|
||||||
face_dressing_fill_class: draft.face_dressing_fill_class,
|
face_dressing_fill_class: draft.face_dressing_fill_class,
|
||||||
@@ -307,6 +311,10 @@ interface DraftSettings {
|
|||||||
ancillary_counts: Record<string, number | null>;
|
ancillary_counts: Record<string, number | null>;
|
||||||
// 임목축적 등급 — "소림"·"중림"·"밀림". ⚠ 본수가 아니라 축적이다(품셈 9-21 [주]①).
|
// 임목축적 등급 — "소림"·"중림"·"밀림". ⚠ 본수가 아니라 축적이다(품셈 9-21 [주]①).
|
||||||
stand_volume_class: string;
|
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`.
|
// 면고르기 — 갈래 둘(서버 선택지) · 면적 덮어쓰기 둘(`null` = 파종 면적). 칸은 `_Side_FaceDressing`.
|
||||||
face_dressing_cut_class: string;
|
face_dressing_cut_class: string;
|
||||||
face_dressing_fill_class: string;
|
face_dressing_fill_class: string;
|
||||||
@@ -521,13 +529,10 @@ function buildQuantitySidePanel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 면고르기 — 밑수가 파종 면적이라 반영률 바로 밑. 선택지는 서버 목록 그대로(칸은 따로 뺀 파일).
|
// ── 초류종자살포 토질 · 면고르기 — 밑수가 파종 면적이라 반영률 바로 밑. 선택지는 서버 목록 그대로.
|
||||||
appendFaceDressingFields(panel, draft, table?.face_dressing_choices, {
|
const sideHelpers = { field, optionalNumberField, selectField, hintRow };
|
||||||
field,
|
appendSeedSprayField(panel, draft, table?.seed_spray_choices, sideHelpers);
|
||||||
optionalNumberField,
|
appendFaceDressingFields(panel, draft, table?.face_dressing_choices, sideHelpers);
|
||||||
selectField,
|
|
||||||
hintRow,
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 표토 두께 — 표토 운반 부피(제거 ㎡ × T)에 씀 · 제거 줄은 ㎡ 라 안 곱함(2026-09-13 「실무대로」) ──
|
// ── 표토 두께 — 표토 운반 부피(제거 ㎡ × T)에 씀 · 제거 줄은 ㎡ 라 안 곱함(2026-09-13 「실무대로」) ──
|
||||||
// ⚠ 2026-09-08 ㉘ 자기 감사: 서버·엔진은 이 값을 받고 있었는데 **화면에 넣을 칸이 없었다.**
|
// ⚠ 2026-09-08 ㉘ 자기 감사: 서버·엔진은 이 값을 받고 있었는데 **화면에 넣을 칸이 없었다.**
|
||||||
@@ -637,6 +642,12 @@ function buildQuantitySidePanel(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
panel.append(hintRow(L("B08_Quantity_Side_StandVolume_Hint")));
|
panel.append(hintRow(L("B08_Quantity_Side_StandVolume_Hint")));
|
||||||
|
appendRootRemovalFields(panel, draft, table?.root_removal_excavator_choices, {
|
||||||
|
field,
|
||||||
|
optionalNumberField,
|
||||||
|
selectField,
|
||||||
|
hintRow,
|
||||||
|
});
|
||||||
|
|
||||||
// ── 부대시설 개소 — ⚠ **산식으로 만들지 않는다**(확정 13). 넣어야 줄이 선다 ──
|
// ── 부대시설 개소 — ⚠ **산식으로 만들지 않는다**(확정 13). 넣어야 줄이 선다 ──
|
||||||
panel.append(field(L("B08_Quantity_Side_Ancillary"), ""));
|
panel.append(field(L("B08_Quantity_Side_Ancillary"), ""));
|
||||||
@@ -1021,6 +1032,8 @@ export async function renderB08Quantity(root: HTMLElement): Promise<void> {
|
|||||||
topsoil_haul_distance_m: (stored.topsoil_haul_distance_m as number | null) ?? null,
|
topsoil_haul_distance_m: (stored.topsoil_haul_distance_m as number | null) ?? null,
|
||||||
topsoil_target: (stored.topsoil_target as string) ?? "",
|
topsoil_target: (stored.topsoil_target as string) ?? "",
|
||||||
stand_volume_class: (stored.stand_volume_class 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_cut_class: (stored.face_dressing_cut_class as string) ?? "",
|
||||||
face_dressing_fill_class: (stored.face_dressing_fill_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_fill_area_m2: (stored.face_dressing_fill_area_m2 as number | null) ?? null,
|
||||||
|
|||||||
@@ -5,11 +5,13 @@
|
|||||||
*
|
*
|
||||||
* 페이지 본문(`B08_Quantity_UI_Page.ts`)이 700줄을 넘어 새 칸을 이 파일로 뺌(CLAUDE.md 4장).
|
* 페이지 본문(`B08_Quantity_UI_Page.ts`)이 700줄을 넘어 새 칸을 이 파일로 뺌(CLAUDE.md 4장).
|
||||||
* ⚠ 선택지는 **서버가 내려준 목록 그대로**(`face_dressing_choices`) — 화면에 다시 적지 않음(두 벌 금지).
|
* ⚠ 선택지는 **서버가 내려준 목록 그대로**(`face_dressing_choices`) — 화면에 다시 적지 않음(두 벌 금지).
|
||||||
* ⚠ 제안값 없음(판정 Ⓒ) — 첫 보기가 「안 정함」이고 비면 내역 줄이 입력 사유로 섬.
|
* ⚠ 스스로 안 고름(판정 Ⓒ) — 첫 보기가 「안 정함」이고 비면 내역 줄이 입력 사유로 섬. 성토면 제안은
|
||||||
|
* 회색 근거 + [제안값 넣기]를 **누른 때만**(메인 976e5fd9 `fill_suggested`).
|
||||||
* ⚠ 면적은 화면이 셈하지 않음 — 비우면 서버가 파종 면적(초류종자살포 × 반영률)을 씀.
|
* ⚠ 면적은 화면이 셈하지 않음 — 비우면 서버가 파종 면적(초류종자살포 × 반영률)을 씀.
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
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";
|
import type { SideFieldHelpers } from "./B08_Quantity_UI_Side_TreeWaste";
|
||||||
|
|
||||||
function L(key: keyof typeof ui_locales): string {
|
function L(key: keyof typeof ui_locales): string {
|
||||||
@@ -29,6 +31,8 @@ export interface FaceDressingDraft {
|
|||||||
export interface FaceDressingChoices {
|
export interface FaceDressingChoices {
|
||||||
cut: string[];
|
cut: string[];
|
||||||
fill: string[];
|
fill: string[];
|
||||||
|
/** 성토면 제안(서버 한 곳 · 실무 관측) — 회색으로 보이고 [제안값 넣기]를 **누른 때만** 들어감. */
|
||||||
|
fill_suggested?: { value: string; basis: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function appendFaceDressingFields(
|
export function appendFaceDressingFields(
|
||||||
@@ -76,11 +80,36 @@ export function appendFaceDressingFields(
|
|||||||
draft.face_dressing_cut_class = v;
|
draft.face_dressing_cut_class = v;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
panel.append(
|
const fillRow = pick(
|
||||||
pick("B08_Quantity_FaceDressing_Fill", draft.face_dressing_fill_class, choices.fill, (v) => {
|
"B08_Quantity_FaceDressing_Fill",
|
||||||
|
draft.face_dressing_fill_class,
|
||||||
|
choices.fill,
|
||||||
|
(v) => {
|
||||||
draft.face_dressing_fill_class = 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(
|
panel.append(
|
||||||
area("B08_Quantity_FaceDressing_FillArea", draft.face_dressing_fill_area_m2, (v) => {
|
area("B08_Quantity_FaceDressing_FillArea", draft.face_dressing_fill_area_m2, (v) => {
|
||||||
draft.face_dressing_fill_area_m2 = v;
|
draft.face_dressing_fill_area_m2 = v;
|
||||||
|
|||||||
@@ -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")),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -86,9 +86,13 @@ export interface StructureSheet extends StandardSheetSpec {
|
|||||||
name: string;
|
name: string;
|
||||||
code?: string | null;
|
code?: string | null;
|
||||||
imported_from?: string | null;
|
imported_from?: string | null;
|
||||||
|
/** 뽑아 온 원문 공사명(STmate 고정형) — 프로그램 기본 발행 때 가림. */
|
||||||
|
origin_project?: string | null;
|
||||||
};
|
};
|
||||||
/** 화면이 조작 중 왕복 없이 다시 풀 장 한 벌(L=1, 고친 식 얹힘). */
|
/** 화면이 조작 중 왕복 없이 다시 풀 장 한 벌(L=1, 고친 식 얹힘). */
|
||||||
formula_sheet?: FormulaSheet;
|
formula_sheet?: FormulaSheet;
|
||||||
|
/** ㉱ 고정형 항목 규격(H)이 장 제원과 다름 — 금액은 서되 미확정 급(빨간 테두리·배지). */
|
||||||
|
spec_mismatch?: { item_height_m: number; sheet_height_m: number } | null;
|
||||||
/** 제원 칸의 대안 후보(실무 관측값 등) — 값은 안 바꾸고 보이기만. */
|
/** 제원 칸의 대안 후보(실무 관측값 등) — 값은 안 바꾸고 보이기만. */
|
||||||
var_candidates?: {
|
var_candidates?: {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -258,8 +262,21 @@ function sheetBody(
|
|||||||
: "양식 없음(지금 전개)"),
|
: "양식 없음(지금 전개)"),
|
||||||
),
|
),
|
||||||
// 실무 시트 머리의 「m당」·「개소당」 — 종류마다 다름(통일하지 않음).
|
// 실무 시트 머리의 「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,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
// ㉱ 규격 다름 — 미확정과 같은 급(빨간 테두리 + 배지). 금액은 서고 막지 않음.
|
||||||
|
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장 한 장의 짜임) — 못 그리는 장은 까닭.
|
// 한 장 = 상단 그림 + 하단 표(PLAN 3장 한 장의 짜임) — 못 그리는 장은 까닭.
|
||||||
main.append(head, figureSection(sheet.figure, sheet.figure_reason));
|
main.append(head, figureSection(sheet.figure, sheet.figure_reason));
|
||||||
if (!sheet.rows.length) {
|
if (!sheet.rows.length) {
|
||||||
@@ -414,6 +431,8 @@ export function renderStructureSheets(projectId: string | null): HTMLElement {
|
|||||||
sheetKey: sheet.key,
|
sheetKey: sheet.key,
|
||||||
typeId: sheet.library_item.type_id,
|
typeId: sheet.library_item.type_id,
|
||||||
currentCode: sheet.library_item.code ?? null,
|
currentCode: sheet.library_item.code ?? null,
|
||||||
|
originProject: sheet.library_item.origin_project,
|
||||||
|
defaultName: sheet.title,
|
||||||
isDirty: () => dirty,
|
isDirty: () => dirty,
|
||||||
confirmTake: () => {
|
confirmTake: () => {
|
||||||
const edited = editedRows(sheet);
|
const edited = editedRows(sheet);
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ export function formulaTable(
|
|||||||
saveButton.disabled = !dirty;
|
saveButton.disabled = !dirty;
|
||||||
discardButton.disabled = !dirty;
|
discardButton.disabled = !dirty;
|
||||||
status.textContent = dirty
|
status.textContent = dirty
|
||||||
? "저장 안 한 식 있음 — 값은 이 화면 계산(저장하면 서버가 다시 셈 · 같은 양식의 장 모두에 걸림)"
|
? "저장 안 한 식 있음 — 값은 이 화면 계산(저장하면 서버가 다시 셈 · 같은 양식의 장 모두에 걸림 · 고친 식은 이 양식·프로젝트에 묶여 제원(높이 등)을 바꿔도 따라감)"
|
||||||
: "";
|
: "";
|
||||||
onDirty(dirty);
|
onDirty(dirty);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,9 +10,16 @@
|
|||||||
import { API_BASE_URL } from "@config/config_frontend";
|
import { API_BASE_URL } from "@config/config_frontend";
|
||||||
import { buildStmatePanel } from "./B08_Quantity_UI_StructureSheet_Stmate";
|
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장). */
|
/** 항목 종류 배지 — 양식형 = 제원을 바꾸면 수량이 다시 남 · 고정형 = 박힌 수량(명세 13장). */
|
||||||
const KIND_LABELS: Record<string, string> = { form: "양식형", fixed: "고정형" };
|
const KIND_LABELS: Record<string, string> = { form: "양식형", fixed: "고정형" };
|
||||||
|
/** 이름표 — 「종류 + 제원 요약」을 미리 채워 두고 고치게 함(10-A ⑫ · 코드는 난수라 이름이 흔들려도 안전). */
|
||||||
|
const NAME_TAG_HINT = "이름표(종류 + 제원 요약 · 고칠 수 있음 · 비우면 양식 이름):";
|
||||||
|
|
||||||
interface LibraryItem {
|
interface LibraryItem {
|
||||||
tier: string;
|
tier: string;
|
||||||
@@ -52,6 +59,10 @@ export interface LibraryPanelOptions {
|
|||||||
/** 저장 안 한 식이 있으면 [내 라이브러리에 저장]을 막음 — 저장된 식만 개인 단으로 감. */
|
/** 저장 안 한 식이 있으면 [내 라이브러리에 저장]을 막음 — 저장된 식만 개인 단으로 감. */
|
||||||
isDirty: () => boolean;
|
isDirty: () => boolean;
|
||||||
onImported: (notes: string[]) => Promise<void>;
|
onImported: (notes: string[]) => Promise<void>;
|
||||||
|
/** 이 장 양식이 뽑아 온 원문 공사명 — 프로그램 기본 발행 확인창에 「빼고 발행」을 알림. */
|
||||||
|
originProject?: string | null;
|
||||||
|
/** 저장·발행 이름표 제안 — 장 이름(종류 + 제원 요약). */
|
||||||
|
defaultName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 가져오기 · [내 라이브러리에 저장] · [내 것 지우기] 칸. 개인 단 두 단추는 프로젝트를 안 바꿈. */
|
/** 가져오기 · [내 라이브러리에 저장] · [내 것 지우기] 칸. 개인 단 두 단추는 프로젝트를 안 바꿈. */
|
||||||
@@ -80,13 +91,112 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
|
|||||||
take.className = "b08-spec__save";
|
take.className = "b08-spec__save";
|
||||||
take.textContent = "가져오기";
|
take.textContent = "가져오기";
|
||||||
take.hidden = true;
|
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", () => {
|
load.addEventListener("click", () => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
load.disabled = true;
|
load.disabled = true;
|
||||||
status.textContent = "목록 받는 중…";
|
status.textContent = "목록 받는 중…";
|
||||||
try {
|
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)}`), {
|
await fetch(libraryUrl(projectId, `?type_id=${encodeURIComponent(typeId)}`), {
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
}),
|
}),
|
||||||
@@ -101,8 +211,13 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
|
|||||||
return option;
|
return option;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
list.hidden = take.hidden = items.length === 0;
|
list.hidden = take.hidden = clone.hidden = send.hidden = items.length === 0;
|
||||||
status.textContent = items.length ? "" : "가져올 항목이 없음";
|
syncClone();
|
||||||
|
// 발행 단추 — 서버가 준 권한대로만 보임(회사 = 마스터 · 기본 = 시스템 관리자).
|
||||||
|
toCompany.hidden = !canPublish?.company;
|
||||||
|
toProgram.hidden = !canPublish?.program;
|
||||||
|
status.textContent = items.length ? afterLoad : "가져올 항목이 없음";
|
||||||
|
afterLoad = "";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
status.textContent = error instanceof Error ? error.message : "목록을 받지 못함";
|
status.textContent = error instanceof Error ? error.message : "목록을 받지 못함";
|
||||||
} finally {
|
} finally {
|
||||||
@@ -161,17 +276,17 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
|
|||||||
};
|
};
|
||||||
const save = personal("내 라이브러리에 저장", async () => {
|
const save = personal("내 라이브러리에 저장", async () => {
|
||||||
if (isDirty()) return "저장 안 한 식이 있음 — [식 저장] 먼저";
|
if (isDirty()) return "저장 안 한 식이 있음 — [식 저장] 먼저";
|
||||||
if (
|
const tag = window.prompt(
|
||||||
!window.confirm("이 장의 양식과 고친 식을 내 라이브러리에 저장 — 같은 종류가 있으면 덮어씀")
|
`이 장의 양식과 고친 식을 내 라이브러리에 저장 — 같은 종류가 있으면 덮어씀\n${NAME_TAG_HINT}`,
|
||||||
) {
|
options.defaultName,
|
||||||
return "";
|
);
|
||||||
}
|
if (tag === null) return "";
|
||||||
const result = await readJson<{ edited: number }>(
|
const result = await readJson<{ edited: number }>(
|
||||||
await fetch(libraryUrl(projectId, "/personal"), {
|
await fetch(libraryUrl(projectId, "/personal"), {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ sheet_key: sheetKey }),
|
body: JSON.stringify({ sheet_key: sheetKey, name: tag }),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return `내 라이브러리에 저장함${result.edited ? ` · 고친 식 ${result.edited}줄 포함` : ""}`;
|
return `내 라이브러리에 저장함${result.edited ? ` · 고친 식 ${result.edited}줄 포함` : ""}`;
|
||||||
@@ -187,11 +302,40 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
|
|||||||
);
|
);
|
||||||
return result.deleted ? "내 라이브러리에서 지움" : "지울 내 양식이 없음";
|
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");
|
const mine = document.createElement("div");
|
||||||
mine.className = "b08-sheet__actions";
|
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장).
|
// 고정형 항목을 만드는 둘째 길 — STmate 출력 엑셀에서 호표 하나를 뽑아 개인 단에(PLAN 4장).
|
||||||
panel.append(
|
panel.append(
|
||||||
buildStmatePanel({
|
buildStmatePanel({
|
||||||
|
|||||||
@@ -196,7 +196,11 @@ export function buildStandardSpecPanel(
|
|||||||
slope,
|
slope,
|
||||||
judgedSlope ? `비우면 자동 — 지금 판정값 1:${judgedSlope}` : "비우면 자동으로 판정합니다.",
|
judgedSlope ? `비우면 자동 — 지금 판정값 1:${judgedSlope}` : "비우면 자동으로 판정합니다.",
|
||||||
),
|
),
|
||||||
field("야면석 계수", coeff, "비우면 품셈 열을 씁니다."),
|
field(
|
||||||
|
"야면석 계수",
|
||||||
|
coeff,
|
||||||
|
"비우면 품셈 열을 씁니다. 계수 열을 바꿔도 돌 종류는 그대로(다른 칸)입니다.",
|
||||||
|
),
|
||||||
field("채움 강도 (MPa)", mpa, "비우면 210. 180 은 국가기준 하한입니다."),
|
field("채움 강도 (MPa)", mpa, "비우면 210. 180 은 국가기준 하한입니다."),
|
||||||
field("버림 콘크리트", blinding, "비우면 넣습니다(두께 100㎜) — 빼려면 「안 넣음」."),
|
field("버림 콘크리트", blinding, "비우면 넣습니다(두께 100㎜) — 빼려면 「안 넣음」."),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ function render(table: UnitPriceTable): HTMLElement[] {
|
|||||||
const head = el(
|
const head = el(
|
||||||
"p",
|
"p",
|
||||||
"b08-sheet__head",
|
"b08-sheet__head",
|
||||||
`일위대가 ${table.code} · ${table.unit}당 ${total} (미리보기 — 내역 금액은 원가계산이 셈)`,
|
`일위대가 ${table.code} · ${table.unit}당 ${total} (미리보기 — 내역 금액은 원가계산이 셈 · 하위 일위대가는 5단까지 풀고 더 깊거나 돌면 막힘)`,
|
||||||
);
|
);
|
||||||
if (table.unconfirmed) {
|
if (table.unconfirmed) {
|
||||||
head.append(el("span", "b08-unit__badge", `미확정 ${table.unconfirmed}건`));
|
head.append(el("span", "b08-unit__badge", `미확정 ${table.unconfirmed}건`));
|
||||||
|
|||||||
@@ -233,7 +233,16 @@ export function unitPriceEditor(options: EditorOptions): HTMLElement {
|
|||||||
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
|
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
|
||||||
const items = payload.items ?? [];
|
const items = payload.items ?? [];
|
||||||
results.replaceChildren(
|
results.replaceChildren(
|
||||||
...(items.length ? items.map(choice) : [el("span", "b08-grid__caption", "없음")]),
|
...(items.length
|
||||||
|
? items.map(choice)
|
||||||
|
: [
|
||||||
|
// 후보 0 — 조용히 막히지 않게 다음 길을 적음(갈래 바꿔 찾기 · 수동 단가는 미확정으로 셈).
|
||||||
|
el(
|
||||||
|
"span",
|
||||||
|
"b08-grid__caption",
|
||||||
|
"단가표에 후보 없음 — 낱말·갈래(품셈/자원)를 바꿔 찾거나, 이 줄에 수동 단가를 넣을 것(미확정으로 셈)",
|
||||||
|
),
|
||||||
|
]),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
results.replaceChildren(
|
results.replaceChildren(
|
||||||
|
|||||||
@@ -243,6 +243,8 @@ export interface PreparationRow {
|
|||||||
export interface PreparationTable {
|
export interface PreparationTable {
|
||||||
columns: string[];
|
columns: string[];
|
||||||
rows: PreparationRow[];
|
rows: PreparationRow[];
|
||||||
|
/** 입력하면 서는 줄 수 — 「근거 없음」(`pending_count`)과 갈라 셈(2026-09-14). */
|
||||||
|
input_count?: number;
|
||||||
pending_count: number;
|
pending_count: number;
|
||||||
row_count: number;
|
row_count: number;
|
||||||
}
|
}
|
||||||
@@ -261,7 +263,7 @@ export function renderPreparationGrid(
|
|||||||
|
|
||||||
const caption = document.createElement("p");
|
const caption = document.createElement("p");
|
||||||
caption.className = "b08-grid__caption";
|
caption.className = "b08-grid__caption";
|
||||||
caption.textContent = `${table.row_count}줄 · 값을 낼 근거가 아직 없는 줄 ${table.pending_count}개`;
|
caption.textContent = `${table.row_count}줄 · 입력이 필요한 줄 ${table.input_count ?? 0}개 · 값을 낼 근거가 없는 줄 ${table.pending_count}개`;
|
||||||
const unconfirmed = table.rows.reduce((sum, row) => sum + (row.unconfirmed ?? 0), 0);
|
const unconfirmed = table.rows.reduce((sum, row) => sum + (row.unconfirmed ?? 0), 0);
|
||||||
if (unconfirmed) {
|
if (unconfirmed) {
|
||||||
const badge = document.createElement("span");
|
const badge = document.createElement("span");
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ def chosen_conditions(settings: dict[str, Any] | None) -> list[dict[str, str]]:
|
|||||||
("fuel_region", "유가 지역(시도코드)"),
|
("fuel_region", "유가 지역(시도코드)"),
|
||||||
("transport_distance_km", "기계 수송 거리(편도 ㎞)"),
|
("transport_distance_km", "기계 수송 거리(편도 ㎞)"),
|
||||||
("transport_road", "수송 도로 구분"),
|
("transport_road", "수송 도로 구분"),
|
||||||
|
("transport_trips", "기계 수송 회수(대수 × 왕복)"),
|
||||||
):
|
):
|
||||||
value = str(picked.get(key) or "").strip()
|
value = str(picked.get(key) or "").strip()
|
||||||
if value:
|
if value:
|
||||||
|
|||||||
@@ -45,28 +45,30 @@ from B09_Estimation.B09_Estimation_UnitPrice import (
|
|||||||
|
|
||||||
_ZERO = Decimal(0)
|
_ZERO = Decimal(0)
|
||||||
|
|
||||||
|
# ⬇ 인계 읽기는 `_Input` 으로 옮겼다(2026-09-15 700줄 분리) — 쓰던 이름이 그대로 살게 다시 내보낸다.
|
||||||
|
from B09_Estimation.B09_Estimation_BillOfQuantities_Input import ( # noqa: E402,F401
|
||||||
|
BLOCKED_UNCONFIRMED,
|
||||||
|
SUPPLY_OWNER,
|
||||||
|
SUPPLY_UNKNOWN,
|
||||||
|
BillError,
|
||||||
|
HandoffMaterial,
|
||||||
|
HandoffWorkItem,
|
||||||
|
_decimal,
|
||||||
|
parse_handoff,
|
||||||
|
)
|
||||||
|
|
||||||
#: 총 절취량으로 셀 공종 — 9-3 토사깎기 · 9-4 암절취 · 9-5 발파암(인계 대응표 「흙깎기」 셋).
|
#: 총 절취량으로 셀 공종 — 9-3 토사깎기 · 9-4 암절취 · 9-5 발파암(인계 대응표 「흙깎기」 셋).
|
||||||
#: ⚠ 판에 묶인 절 번호임(명세 17장) — 판이 바뀌면 대응표와 함께 고칠 것.
|
#: ⚠ 판에 묶인 절 번호임(명세 17장) — 판이 바뀌면 대응표와 함께 고칠 것.
|
||||||
CUT_CODE_PREFIXES = ("FP-09-03", "FP-09-04", "FP-09-05")
|
CUT_CODE_PREFIXES = ("FP-09-03", "FP-09-04", "FP-09-05")
|
||||||
#: 구조물도 양식 호표 코드 머리(명세 2장 자체 확장) — 금액은 B08 일위대가 엔진을 B09 단가표로 셈.
|
#: 구조물도 양식 호표 코드 머리(명세 2장 자체 확장) — 금액은 B08 일위대가 엔진을 B09 단가표로 셈.
|
||||||
STRUCTURE_PRICE_PREFIX = "AX-ST-"
|
STRUCTURE_PRICE_PREFIX = "AX-ST-"
|
||||||
|
|
||||||
#: 자재 공급 구분이 안 갈린 값. B08 이 실제로 이 값을 보낸다(2026-09-08 실물 확인).
|
|
||||||
#: **관급자재대에도, 도급 재료비에도 넣지 않는다** — 어느 쪽에 넣어도 총액이 틀린다.
|
|
||||||
SUPPLY_UNKNOWN = "unknown"
|
|
||||||
|
|
||||||
#: 관급 — 총원가 밖 별도 표기라 사급과 **가는 자리가 다르다**(PLAN 8-2).
|
|
||||||
SUPPLY_OWNER = "owner_supplied"
|
|
||||||
|
|
||||||
#: 막힘 갈래를 사람 말로. **할 일이 다르므로 화면에서 갈라 보인다.**
|
#: 막힘 갈래를 사람 말로. **할 일이 다르므로 화면에서 갈라 보인다.**
|
||||||
#: ⚠ `blocked_kind` 가 **`None` 인데 `in_bill=False`** 면 **막힌 줄이 아니다** —
|
#: ⚠ `blocked_kind` 가 **`None` 인데 `in_bill=False`** 면 **막힌 줄이 아니다** —
|
||||||
#: 「다른 표에서 이미 섬」(벌목·지장목제거)이거나 「이 노선엔 없음」(사방 시설)이다.
|
#: 「다른 표에서 이미 섬」(벌목·지장목제거)이거나 「이 노선엔 없음」(사방 시설)이다.
|
||||||
#: 그것을 「우리가 만들어야 하는 것」에 얹으면 **결국 이중계상으로 간다**(㉠~㉦ 규칙).
|
#: 그것을 「우리가 만들어야 하는 것」에 얹으면 **결국 이중계상으로 간다**(㉠~㉦ 규칙).
|
||||||
_NOT_OUR_ROW = "not_our_row"
|
_NOT_OUR_ROW = "not_our_row"
|
||||||
|
|
||||||
#: B08 `BLOCKED_UNCONFIRMED` 와 같은 글 — 치수 없이 기본값으로 선 줄.
|
|
||||||
BLOCKED_UNCONFIRMED = "unconfirmed"
|
|
||||||
|
|
||||||
_BLOCKED_LABELS = {
|
_BLOCKED_LABELS = {
|
||||||
_NOT_OUR_ROW: "여기서 세지 않는 줄",
|
_NOT_OUR_ROW: "여기서 세지 않는 줄",
|
||||||
BLOCKED_UNCONFIRMED: "미확정 — 금액에 안 들어감",
|
BLOCKED_UNCONFIRMED: "미확정 — 금액에 안 들어감",
|
||||||
@@ -76,84 +78,6 @@ _BLOCKED_LABELS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class BillError(ValueError):
|
|
||||||
"""내역서를 세울 수 없는 경우. 빈 표를 돌려주지 않고 멈춘다."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class HandoffWorkItem:
|
|
||||||
"""B08 인계 공종 한 줄. **수량은 B08 것이 정본이다** — 여기서 다시 세지 않는다."""
|
|
||||||
|
|
||||||
work_item_code: str | None
|
|
||||||
name: str
|
|
||||||
spec: str
|
|
||||||
unit: str
|
|
||||||
quantity: Decimal
|
|
||||||
in_bill: bool
|
|
||||||
#: 운반 줄에만 있다 — 거리(m)와 수단. 무대(20 m 이내)는 `free_haul` 로 온다.
|
|
||||||
haul_distance_m: Decimal | None = None
|
|
||||||
haul_equipment: str | None = None
|
|
||||||
in_bill_reason: str = ""
|
|
||||||
origin: str = ""
|
|
||||||
ground_class: str = ""
|
|
||||||
#: 반영률(%) — B08 이 이미 곱했으면 산출근거에만 적고 **여기서 또 곱하지 않는다**.
|
|
||||||
application_ratio_pct: Decimal | None = None
|
|
||||||
#: 반영률 적용 **전** 수량. 산출근거에만 쓴다.
|
|
||||||
quantity_gross: Decimal | None = None
|
|
||||||
#: 성·절토처럼 율이 갈리는 경우의 몫별 율·수량 — 문장 파싱 없이 그대로 그린다.
|
|
||||||
application_ratio_breakdown: dict | None = None
|
|
||||||
quantity_breakdown: dict | None = None
|
|
||||||
#: 묶음 줄(옹벽처럼 품셈에 그 공종이 없는 것) — 무엇으로 이루어지는지.
|
|
||||||
composite_parts: tuple = ()
|
|
||||||
#: 묶음인데 아직 못 채운 조각 — 「단가 없음」과 「물량 없음」을 갈라 적는다.
|
|
||||||
composite_not_ready: tuple = ()
|
|
||||||
structure_kind: str = ""
|
|
||||||
#: B08 이 적어 보낸 막힘 사유 — **문구는 B08 것을 그대로 쓴다**(두 벌로 짜지 않는다).
|
|
||||||
blocked_reason: str = ""
|
|
||||||
#: 막힘 갈래 — `input_missing`(사용자가 입력하면 풀림) /
|
|
||||||
#: `unit_data_missing`·`formula_missing`(우리가 만들어야 함). 할 일이 다르므로 가른다.
|
|
||||||
blocked_kind: str = ""
|
|
||||||
#: 갈래 축·원본값 — 「stone_cm」·「60~80」. **키 문자열은 우리가 만든다**(두 창 합의).
|
|
||||||
variant_axis: str = ""
|
|
||||||
variant_value: str = ""
|
|
||||||
#: 규격 갈래를 B08 이 판정해 보낸 것(「철근구조물」)과 그 근거 문구.
|
|
||||||
#: **근거는 산출근거 칸에 그대로 적는다** — 우리가 다시 지어내지 않는다.
|
|
||||||
spec_class: str = ""
|
|
||||||
spec_class_basis: str = ""
|
|
||||||
#: 이 줄의 공종 코드가 본 품셈 판 — 마스터 판과 다르면 값을 안 씀(명세 17장).
|
|
||||||
pum_edition: str = ""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def display_name(self) -> str:
|
|
||||||
return f"{self.name} {self.spec}".strip()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def unconfirmed(self) -> bool:
|
|
||||||
"""B08 이 치수 없이 기본값으로 세운 줄(2026-09-14 브레인 판정) — 내역 제자리에
|
|
||||||
**빈 금액 + 빨간 테두리**로 서고 합계에 안 듦 · 머리에 「미확정 N건 — 금액에 안 들어감」."""
|
|
||||||
return self.blocked_kind == BLOCKED_UNCONFIRMED
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class HandoffMaterial:
|
|
||||||
"""B08 인계 자재 한 줄. `work_item_code` 칸이 **아예 없는** 별도 벌이다(계약 확정)."""
|
|
||||||
|
|
||||||
material_name: str
|
|
||||||
spec: str
|
|
||||||
unit: str
|
|
||||||
net_amount: Decimal
|
|
||||||
total_amount: Decimal
|
|
||||||
supply_type: str
|
|
||||||
surcharge_pct: Decimal | None = None
|
|
||||||
surcharge_note: str = ""
|
|
||||||
install_by: str | None = None
|
|
||||||
source_structure: tuple[str, ...] = ()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def display_name(self) -> str:
|
|
||||||
return f"{self.material_name} {self.spec}".strip()
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class BillRow:
|
class BillRow:
|
||||||
"""내역서 한 줄. 머리(그룹)줄은 `is_group=True` 이고 수량·단가가 없다."""
|
"""내역서 한 줄. 머리(그룹)줄은 `is_group=True` 이고 수량·단가가 없다."""
|
||||||
@@ -308,66 +232,6 @@ class BillResult:
|
|||||||
return sum((r.amount_krw or _ZERO for r in self.rows if not r.is_group), _ZERO)
|
return sum((r.amount_krw or _ZERO for r in self.rows if not r.is_group), _ZERO)
|
||||||
|
|
||||||
|
|
||||||
def _decimal(value: Any, default: Decimal | None = _ZERO) -> Decimal | None:
|
|
||||||
if value is None or value == "":
|
|
||||||
return default
|
|
||||||
return Decimal(str(value))
|
|
||||||
|
|
||||||
|
|
||||||
def parse_handoff(payload: dict[str, Any]) -> tuple[list[HandoffWorkItem], list[HandoffMaterial]]:
|
|
||||||
"""인계 응답을 우리 자료형으로 옮긴다. **모르는 칸을 채우지 않는다.**"""
|
|
||||||
if "work_items" not in payload or "materials" not in payload:
|
|
||||||
raise BillError("인계 응답에 `work_items`·`materials` 두 벌이 다 있어야 합니다.")
|
|
||||||
|
|
||||||
work_items = [
|
|
||||||
HandoffWorkItem(
|
|
||||||
work_item_code=row.get("work_item_code"),
|
|
||||||
name=row.get("name", ""),
|
|
||||||
spec=row.get("spec") or "",
|
|
||||||
unit=row.get("unit") or "",
|
|
||||||
quantity=_decimal(row.get("quantity")) or _ZERO,
|
|
||||||
in_bill=bool(row.get("in_bill", True)),
|
|
||||||
in_bill_reason=row.get("in_bill_reason") or "",
|
|
||||||
origin=row.get("origin") or "",
|
|
||||||
ground_class=row.get("ground_class") or "",
|
|
||||||
haul_distance_m=_decimal(row.get("haul_distance_m"), None),
|
|
||||||
haul_equipment=row.get("haul_equipment"),
|
|
||||||
# ⚠ 있으면 **적기만** 한다 — 곱하기는 B08 한 곳에서만(2026-09-08 이견 ①).
|
|
||||||
application_ratio_pct=_decimal(row.get("application_ratio_pct"), None),
|
|
||||||
quantity_gross=_decimal(row.get("quantity_gross"), None),
|
|
||||||
application_ratio_breakdown=row.get("application_ratio_breakdown"),
|
|
||||||
quantity_breakdown=row.get("quantity_breakdown"),
|
|
||||||
composite_parts=tuple(row.get("composite_parts") or ()),
|
|
||||||
composite_not_ready=tuple(row.get("composite_not_ready") or ()),
|
|
||||||
structure_kind=row.get("structure_kind") or "",
|
|
||||||
blocked_reason=row.get("blocked_reason") or "",
|
|
||||||
blocked_kind=row.get("blocked_kind") or "",
|
|
||||||
variant_axis=row.get("variant_axis") or "",
|
|
||||||
variant_value=str(row.get("variant_value") or ""),
|
|
||||||
spec_class=row.get("spec_class") or "",
|
|
||||||
spec_class_basis=row.get("spec_class_basis") or "",
|
|
||||||
pum_edition=str(row.get("pum_edition") or ""),
|
|
||||||
)
|
|
||||||
for row in payload["work_items"]
|
|
||||||
]
|
|
||||||
materials = [
|
|
||||||
HandoffMaterial(
|
|
||||||
material_name=row.get("material_name", ""),
|
|
||||||
spec=row.get("spec") or "",
|
|
||||||
unit=row.get("unit") or "",
|
|
||||||
net_amount=_decimal(row.get("net_amount")) or _ZERO,
|
|
||||||
total_amount=_decimal(row.get("total_amount")) or _ZERO,
|
|
||||||
supply_type=row.get("supply_type") or SUPPLY_UNKNOWN,
|
|
||||||
surcharge_pct=_decimal(row.get("surcharge_pct"), None),
|
|
||||||
surcharge_note=row.get("surcharge_note") or "",
|
|
||||||
install_by=row.get("install_by"),
|
|
||||||
source_structure=tuple(row.get("source_structure") or ()),
|
|
||||||
)
|
|
||||||
for row in payload["materials"]
|
|
||||||
]
|
|
||||||
return work_items, materials
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class _MasterNode:
|
class _MasterNode:
|
||||||
code: str
|
code: str
|
||||||
@@ -414,11 +278,11 @@ from B09_Estimation.B09_Estimation_BillOfQuantities_Rows import ( # noqa: E402
|
|||||||
_composite_row,
|
_composite_row,
|
||||||
_excluded_row,
|
_excluded_row,
|
||||||
_leaf_row,
|
_leaf_row,
|
||||||
_material_row,
|
|
||||||
_structure_price_row,
|
_structure_price_row,
|
||||||
_sum_groups,
|
_sum_groups,
|
||||||
bill_line, # noqa: F401 — 내역 줄 성분별 절사(골든셋 시험이 여기서 부름)
|
bill_line, # noqa: F401 — 내역 줄 성분별 절사(골든셋 시험이 여기서 부름)
|
||||||
)
|
)
|
||||||
|
from B09_Estimation.B09_Estimation_BillOfQuantities_Materials import _material_row # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
def build_bill(
|
def build_bill(
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
"""B09 원가계산 — ④ 예산내역서 **인계 읽기**(자료형 · 파싱).
|
||||||
|
|
||||||
|
`B09_Estimation_BillOfQuantities` 가 700줄에 닿아 **그대로 옮겨 온 자리**다
|
||||||
|
(2026-09-15 · 순수 분리 — 글자 한 자 안 고쳤다). 조판·계층은 그쪽에 그대로 있다.
|
||||||
|
|
||||||
|
⚠ **모르는 칸을 채우지 않는다** — B08 이 안 보낸 값은 `None`·빈 문자열로 남긴다.
|
||||||
|
⚠ 쓰던 자리가 안 바뀌도록 본 모듈이 다시 내보낸다 —
|
||||||
|
`from ...BillOfQuantities import HandoffWorkItem` 가 그대로 돈다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
_ZERO = Decimal(0)
|
||||||
|
|
||||||
|
#: 자재 공급 구분이 안 갈린 값. B08 이 실제로 이 값을 보낸다(2026-09-08 실물 확인).
|
||||||
|
#: **관급자재대에도, 도급 재료비에도 넣지 않는다** — 어느 쪽에 넣어도 총액이 틀린다.
|
||||||
|
SUPPLY_UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
#: 관급 — 총원가 밖 별도 표기라 사급과 **가는 자리가 다르다**(PLAN 8-2).
|
||||||
|
SUPPLY_OWNER = "owner_supplied"
|
||||||
|
|
||||||
|
#: B08 `BLOCKED_UNCONFIRMED` 와 같은 글 — 치수 없이 기본값으로 선 줄.
|
||||||
|
BLOCKED_UNCONFIRMED = "unconfirmed"
|
||||||
|
|
||||||
|
|
||||||
|
class BillError(ValueError):
|
||||||
|
"""내역서를 세울 수 없는 경우. 빈 표를 돌려주지 않고 멈춘다."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HandoffWorkItem:
|
||||||
|
"""B08 인계 공종 한 줄. **수량은 B08 것이 정본이다** — 여기서 다시 세지 않는다."""
|
||||||
|
|
||||||
|
work_item_code: str | None
|
||||||
|
name: str
|
||||||
|
spec: str
|
||||||
|
unit: str
|
||||||
|
quantity: Decimal
|
||||||
|
in_bill: bool
|
||||||
|
#: 운반 줄에만 있다 — 거리(m)와 수단. 무대(20 m 이내)는 `free_haul` 로 온다.
|
||||||
|
haul_distance_m: Decimal | None = None
|
||||||
|
haul_equipment: str | None = None
|
||||||
|
in_bill_reason: str = ""
|
||||||
|
origin: str = ""
|
||||||
|
ground_class: str = ""
|
||||||
|
#: 반영률(%) — B08 이 이미 곱했으면 산출근거에만 적고 **여기서 또 곱하지 않는다**.
|
||||||
|
application_ratio_pct: Decimal | None = None
|
||||||
|
#: 반영률 적용 **전** 수량. 산출근거에만 쓴다.
|
||||||
|
quantity_gross: Decimal | None = None
|
||||||
|
#: 성·절토처럼 율이 갈리는 경우의 몫별 율·수량 — 문장 파싱 없이 그대로 그린다.
|
||||||
|
application_ratio_breakdown: dict | None = None
|
||||||
|
quantity_breakdown: dict | None = None
|
||||||
|
#: 묶음 줄(옹벽처럼 품셈에 그 공종이 없는 것) — 무엇으로 이루어지는지.
|
||||||
|
composite_parts: tuple = ()
|
||||||
|
#: 묶음인데 아직 못 채운 조각 — 「단가 없음」과 「물량 없음」을 갈라 적는다.
|
||||||
|
composite_not_ready: tuple = ()
|
||||||
|
structure_kind: str = ""
|
||||||
|
#: B08 이 적어 보낸 막힘 사유 — **문구는 B08 것을 그대로 쓴다**(두 벌로 짜지 않는다).
|
||||||
|
blocked_reason: str = ""
|
||||||
|
#: 막힘 갈래 — `input_missing`(사용자가 입력하면 풀림) /
|
||||||
|
#: `unit_data_missing`·`formula_missing`(우리가 만들어야 함). 할 일이 다르므로 가른다.
|
||||||
|
blocked_kind: str = ""
|
||||||
|
#: 갈래 축·원본값 — 「stone_cm」·「60~80」. **키 문자열은 우리가 만든다**(두 창 합의).
|
||||||
|
variant_axis: str = ""
|
||||||
|
variant_value: str = ""
|
||||||
|
#: 규격 갈래를 B08 이 판정해 보낸 것(「철근구조물」)과 그 근거 문구.
|
||||||
|
#: **근거는 산출근거 칸에 그대로 적는다** — 우리가 다시 지어내지 않는다.
|
||||||
|
spec_class: str = ""
|
||||||
|
spec_class_basis: str = ""
|
||||||
|
#: 이 줄의 공종 코드가 본 품셈 판 — 마스터 판과 다르면 값을 안 씀(명세 17장).
|
||||||
|
pum_edition: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_name(self) -> str:
|
||||||
|
return f"{self.name} {self.spec}".strip()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def unconfirmed(self) -> bool:
|
||||||
|
"""B08 이 치수 없이 기본값으로 세운 줄(2026-09-14 브레인 판정) — 내역 제자리에
|
||||||
|
**빈 금액 + 빨간 테두리**로 서고 합계에 안 듦 · 머리에 「미확정 N건 — 금액에 안 들어감」."""
|
||||||
|
return self.blocked_kind == BLOCKED_UNCONFIRMED
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HandoffMaterial:
|
||||||
|
"""B08 인계 자재 한 줄. `work_item_code` 칸이 **아예 없는** 별도 벌이다(계약 확정)."""
|
||||||
|
|
||||||
|
material_name: str
|
||||||
|
spec: str
|
||||||
|
unit: str
|
||||||
|
net_amount: Decimal
|
||||||
|
total_amount: Decimal
|
||||||
|
supply_type: str
|
||||||
|
surcharge_pct: Decimal | None = None
|
||||||
|
surcharge_note: str = ""
|
||||||
|
install_by: str | None = None
|
||||||
|
source_structure: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_name(self) -> str:
|
||||||
|
return f"{self.material_name} {self.spec}".strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _decimal(value: Any, default: Decimal | None = _ZERO) -> Decimal | None:
|
||||||
|
if value is None or value == "":
|
||||||
|
return default
|
||||||
|
return Decimal(str(value))
|
||||||
|
|
||||||
|
|
||||||
|
def parse_handoff(payload: dict[str, Any]) -> tuple[list[HandoffWorkItem], list[HandoffMaterial]]:
|
||||||
|
"""인계 응답을 우리 자료형으로 옮긴다. **모르는 칸을 채우지 않는다.**"""
|
||||||
|
if "work_items" not in payload or "materials" not in payload:
|
||||||
|
raise BillError("인계 응답에 `work_items`·`materials` 두 벌이 다 있어야 합니다.")
|
||||||
|
|
||||||
|
work_items = [
|
||||||
|
HandoffWorkItem(
|
||||||
|
work_item_code=row.get("work_item_code"),
|
||||||
|
name=row.get("name", ""),
|
||||||
|
spec=row.get("spec") or "",
|
||||||
|
unit=row.get("unit") or "",
|
||||||
|
quantity=_decimal(row.get("quantity")) or _ZERO,
|
||||||
|
in_bill=bool(row.get("in_bill", True)),
|
||||||
|
in_bill_reason=row.get("in_bill_reason") or "",
|
||||||
|
origin=row.get("origin") or "",
|
||||||
|
ground_class=row.get("ground_class") or "",
|
||||||
|
haul_distance_m=_decimal(row.get("haul_distance_m"), None),
|
||||||
|
haul_equipment=row.get("haul_equipment"),
|
||||||
|
# ⚠ 있으면 **적기만** 한다 — 곱하기는 B08 한 곳에서만(2026-09-08 이견 ①).
|
||||||
|
application_ratio_pct=_decimal(row.get("application_ratio_pct"), None),
|
||||||
|
quantity_gross=_decimal(row.get("quantity_gross"), None),
|
||||||
|
application_ratio_breakdown=row.get("application_ratio_breakdown"),
|
||||||
|
quantity_breakdown=row.get("quantity_breakdown"),
|
||||||
|
composite_parts=tuple(row.get("composite_parts") or ()),
|
||||||
|
composite_not_ready=tuple(row.get("composite_not_ready") or ()),
|
||||||
|
structure_kind=row.get("structure_kind") or "",
|
||||||
|
blocked_reason=row.get("blocked_reason") or "",
|
||||||
|
blocked_kind=row.get("blocked_kind") or "",
|
||||||
|
variant_axis=row.get("variant_axis") or "",
|
||||||
|
variant_value=str(row.get("variant_value") or ""),
|
||||||
|
spec_class=row.get("spec_class") or "",
|
||||||
|
spec_class_basis=row.get("spec_class_basis") or "",
|
||||||
|
pum_edition=str(row.get("pum_edition") or ""),
|
||||||
|
)
|
||||||
|
for row in payload["work_items"]
|
||||||
|
]
|
||||||
|
materials = [
|
||||||
|
HandoffMaterial(
|
||||||
|
material_name=row.get("material_name", ""),
|
||||||
|
spec=row.get("spec") or "",
|
||||||
|
unit=row.get("unit") or "",
|
||||||
|
net_amount=_decimal(row.get("net_amount")) or _ZERO,
|
||||||
|
total_amount=_decimal(row.get("total_amount")) or _ZERO,
|
||||||
|
supply_type=row.get("supply_type") or SUPPLY_UNKNOWN,
|
||||||
|
surcharge_pct=_decimal(row.get("surcharge_pct"), None),
|
||||||
|
surcharge_note=row.get("surcharge_note") or "",
|
||||||
|
install_by=row.get("install_by"),
|
||||||
|
source_structure=tuple(row.get("source_structure") or ()),
|
||||||
|
)
|
||||||
|
for row in payload["materials"]
|
||||||
|
]
|
||||||
|
return work_items, materials
|
||||||
@@ -15,7 +15,13 @@ from __future__ import annotations
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
from B09_Estimation.B09_Estimation_BillOfQuantities import BillResult, BillRow
|
from B09_Estimation.B09_Estimation_BillOfQuantities import (
|
||||||
|
SUPPLY_OWNER,
|
||||||
|
SUPPLY_UNKNOWN,
|
||||||
|
BillResult,
|
||||||
|
BillRow,
|
||||||
|
HandoffMaterial,
|
||||||
|
)
|
||||||
from B09_Estimation.B09_Estimation_PriceBook import Money3, PriceKind
|
from B09_Estimation.B09_Estimation_PriceBook import Money3, PriceKind
|
||||||
from B09_Estimation.B09_Estimation_UnitPrice import FUEL_CODE_PREFIX, UnitPriceBuild
|
from B09_Estimation.B09_Estimation_UnitPrice import FUEL_CODE_PREFIX, UnitPriceBuild
|
||||||
|
|
||||||
@@ -24,6 +30,71 @@ DOUBLE_COUNT_SUSPECT = "double_count_suspect"
|
|||||||
_ZERO = Decimal(0)
|
_ZERO = Decimal(0)
|
||||||
|
|
||||||
|
|
||||||
|
def _material_row(
|
||||||
|
material: HandoffMaterial, result: BillResult, manual: dict | None = None
|
||||||
|
) -> BillRow:
|
||||||
|
"""자재 한 줄. 공급 구분이 안 갈렸으면 **어느 쪽에도 안 넣는다**.
|
||||||
|
|
||||||
|
`manual` — 「자재 단가」 수동 단가(키 「이름 규격」). 사급 줄에 값이 있으면 빠진 목록에 안 올림
|
||||||
|
(금액은 본체 「자재(사급)」 줄이 셈 — `BillOfQuantities_Materials`).
|
||||||
|
"""
|
||||||
|
row = BillRow(
|
||||||
|
item_no="",
|
||||||
|
level=1,
|
||||||
|
code=None,
|
||||||
|
name=material.material_name,
|
||||||
|
spec=material.spec,
|
||||||
|
unit=material.unit,
|
||||||
|
quantity=material.total_amount,
|
||||||
|
)
|
||||||
|
# 할증 사유는 **수량**에 닿는다 — 할증이 곱해진 뒤의 수량이기 때문이다.
|
||||||
|
row.add_note("quantity", material.surcharge_note)
|
||||||
|
if material.supply_type == SUPPLY_UNKNOWN:
|
||||||
|
row.add_note(
|
||||||
|
"", "관급·사급이 안 갈렸습니다 — 관급자재대에도, 도급 재료비에도 넣지 않습니다."
|
||||||
|
)
|
||||||
|
result.missing.append(
|
||||||
|
{
|
||||||
|
"name": material.display_name,
|
||||||
|
"unit": material.unit,
|
||||||
|
"quantity": str(material.total_amount),
|
||||||
|
"reason": "공급 구분 미정(unknown)",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return row
|
||||||
|
# ⚠ **관급을 「사급」이라 적으면 안 된다** (2026-09-08 메인 창 실측 — 물구멍·야면석이
|
||||||
|
# `owner_supplied` 인데 「사급 자재 단가 미확보」로 뜨고 있었다). 갈래마다 **가는 자리도
|
||||||
|
# 원천도 다르다** — 관급은 총원가 밖 관급자재대(나라장터), 사급은 도급 재료비(물가지).
|
||||||
|
if material.supply_type == SUPPLY_OWNER:
|
||||||
|
row.add_note(
|
||||||
|
"unit_price_krw",
|
||||||
|
"관급 자재 단가 미확보 — 나라장터 목록에 이 품목이 없습니다. "
|
||||||
|
"관급자재대(총원가 밖 별도 표기)로 갑니다.",
|
||||||
|
)
|
||||||
|
reason = "관급 자재 단가 없음"
|
||||||
|
elif f"{material.material_name} {material.spec}".strip() in (manual or {}):
|
||||||
|
row.add_note(
|
||||||
|
"unit_price_krw", "⚠ 사급 자재 수동 단가(미확정) — 본체 「자재(사급)」 줄로 섬"
|
||||||
|
)
|
||||||
|
return row
|
||||||
|
else:
|
||||||
|
row.add_note(
|
||||||
|
"unit_price_krw", "사급 자재 단가 미확보 — 「자재 단가」 탭에서 수동 입력 대기."
|
||||||
|
)
|
||||||
|
reason = "사급 자재 단가 없음(미결 No.18)"
|
||||||
|
|
||||||
|
result.missing.append(
|
||||||
|
{
|
||||||
|
"name": material.display_name,
|
||||||
|
"unit": material.unit,
|
||||||
|
"quantity": str(material.total_amount),
|
||||||
|
"reason": reason,
|
||||||
|
"supply_type": material.supply_type,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
def _flat(text: Any) -> str:
|
def _flat(text: Any) -> str:
|
||||||
return "".join(str(text or "").split())
|
return "".join(str(text or "").split())
|
||||||
|
|
||||||
|
|||||||
@@ -9,14 +9,12 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from B09_Estimation.B09_Estimation_BillOfQuantities import (
|
from B09_Estimation.B09_Estimation_BillOfQuantities import (
|
||||||
SUPPLY_OWNER,
|
|
||||||
SUPPLY_UNKNOWN,
|
|
||||||
BillResult,
|
BillResult,
|
||||||
BillRow,
|
BillRow,
|
||||||
HandoffMaterial,
|
|
||||||
HandoffWorkItem,
|
HandoffWorkItem,
|
||||||
_BLOCKED_LABELS,
|
_BLOCKED_LABELS,
|
||||||
_MasterNode,
|
_MasterNode,
|
||||||
@@ -25,6 +23,7 @@ from B09_Estimation.B09_Estimation_BillOfQuantities import (
|
|||||||
from B09_Estimation.B09_Estimation_MachineProductivity_Dump import (
|
from B09_Estimation.B09_Estimation_MachineProductivity_Dump import (
|
||||||
DUMP_PARENT,
|
DUMP_PARENT,
|
||||||
LOADING_EQUIPMENT,
|
LOADING_EQUIPMENT,
|
||||||
|
distance_label,
|
||||||
dump_child_for,
|
dump_child_for,
|
||||||
dump_title_code,
|
dump_title_code,
|
||||||
loading_title_code,
|
loading_title_code,
|
||||||
@@ -40,6 +39,13 @@ from B09_Estimation.B09_Estimation_UnitPrice import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def spec_with_variant(spec: str, variant: str) -> str:
|
||||||
|
"""규격에 갈래를 한 번만 — 적힌 갈래(Ø800·리핑암)는 안 붙임(㉵) · 규격 글로 시작하면 갈음."""
|
||||||
|
if re.search(rf"(?<![\d.]){re.escape(variant)}(?![\d.])", spec): # 숫자 경계: Ø1800 ≠ 800
|
||||||
|
return spec
|
||||||
|
return variant if variant.startswith(spec) else f"{spec} {variant}"
|
||||||
|
|
||||||
|
|
||||||
def bill_line(unit: Money3, quantity) -> Money3:
|
def bill_line(unit: Money3, quantity) -> Money3:
|
||||||
"""내역 줄 금액 — **성분마다** `절사(수량 × 성분 단가)`, 줄 합계는 셋의 합(명세 7장).
|
"""내역 줄 금액 — **성분마다** `절사(수량 × 성분 단가)`, 줄 합계는 셋의 합(명세 7장).
|
||||||
|
|
||||||
@@ -112,6 +118,7 @@ def _composite_row(
|
|||||||
in_bill=item.in_bill,
|
in_bill=item.in_bill,
|
||||||
)
|
)
|
||||||
missing_parts: list[str] = []
|
missing_parts: list[str] = []
|
||||||
|
reasons: list[str] = []
|
||||||
money = None
|
money = None
|
||||||
for part in item.composite_parts:
|
for part in item.composite_parts:
|
||||||
code = str(part.get("code") or "")
|
code = str(part.get("code") or "")
|
||||||
@@ -119,12 +126,24 @@ def _composite_row(
|
|||||||
if not code or amount is None or f"B-{code}" not in unit_prices.book.titles:
|
if not code or amount is None or f"B-{code}" not in unit_prices.book.titles:
|
||||||
missing_parts.append(code or str(part.get("name") or "이름 없음"))
|
missing_parts.append(code or str(part.get("name") or "이름 없음"))
|
||||||
continue
|
continue
|
||||||
|
# 조각도 보통 줄과 같은 두 검사 — 일부 몫만 선 단가·밑수 모르는 표를 묶음에 더하면
|
||||||
|
# 묶음 줄만 온전한 금액처럼 섬(2026-09-14 ㉱ 구조 결함).
|
||||||
|
plain = code.split("#", 1)[0]
|
||||||
|
covered = unit_prices.partial_ratio.get(plain)
|
||||||
|
basis = unit_prices.basis_missing.get(plain)
|
||||||
|
if covered is not None or basis:
|
||||||
|
missing_parts.append(code)
|
||||||
|
reasons.append(
|
||||||
|
f"{code}: 단가 일부만 섬(붙은 몫 {covered}%)"
|
||||||
|
if covered is not None
|
||||||
|
else f"{code}: 밑수 미확보 — 원문 {basis}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
# 묶음도 호표 한 장 — 조각 줄 0.1원 · 성분 소계 원 미만 절사(아래 `floored`, 명세 7장).
|
# 묶음도 호표 한 장 — 조각 줄 0.1원 · 성분 소계 원 미만 절사(아래 `floored`, 명세 7장).
|
||||||
scaled = unit_prices.book.resolve(f"B-{code}").scaled(amount).floored(Decimal("0.1"))
|
scaled = unit_prices.book.resolve(f"B-{code}").scaled(amount).floored(Decimal("0.1"))
|
||||||
money = scaled if money is None else money + scaled
|
money = scaled if money is None else money + scaled
|
||||||
row.parts.append((f"B-{code}", amount))
|
row.parts.append((f"B-{code}", amount))
|
||||||
|
|
||||||
reasons: list[str] = []
|
|
||||||
for pending in item.composite_not_ready:
|
for pending in item.composite_not_ready:
|
||||||
# 「단가 없음」과 「물량 없음」을 가른다 — 사유가 다르면 할 일도 다르다.
|
# 「단가 없음」과 「물량 없음」을 가른다 — 사유가 다르면 할 일도 다르다.
|
||||||
if isinstance(pending, str):
|
if isinstance(pending, str):
|
||||||
@@ -362,7 +381,7 @@ def _leaf_row(
|
|||||||
return row
|
return row
|
||||||
price_code = wanted
|
price_code = wanted
|
||||||
if not loading:
|
if not loading:
|
||||||
row.spec = f"{row.spec} L={item.haul_distance_m}m".strip()
|
row.spec = f"{row.spec} {distance_label(item.haul_distance_m)}".strip()
|
||||||
if price_code not in unit_prices.book.titles:
|
if price_code not in unit_prices.book.titles:
|
||||||
# B08 은 **의미**(어느 공종·어느 제원)만 보내고 갈래 키는 우리가 만든다.
|
# B08 은 **의미**(어느 공종·어느 제원)만 보내고 갈래 키는 우리가 만든다.
|
||||||
# 못 맞추면 후보를 보이는 길로 내려간다 — 가까운 갈래를 임의로 고르지 않는다.
|
# 못 맞추면 후보를 보이는 길로 내려간다 — 가까운 갈래를 임의로 고르지 않는다.
|
||||||
@@ -374,8 +393,7 @@ def _leaf_row(
|
|||||||
default = unit_prices.default_variants.get(node.code)
|
default = unit_prices.default_variants.get(node.code)
|
||||||
if picked is not None:
|
if picked is not None:
|
||||||
price_code = picked
|
price_code = picked
|
||||||
variant = str(item.variant_value) # 규격 글로 시작하는 갈래는 한 번만(면고르기)
|
row.spec = spec_with_variant(row.spec, str(item.variant_value))
|
||||||
row.spec = variant if variant.startswith(row.spec) else f"{row.spec} {variant}"
|
|
||||||
elif default is not None:
|
elif default is not None:
|
||||||
# 표에 없거나 안 준 암질(풍화암·암) — **원문이 정한 갈래**로만 선다(9-4-1 [주]① 평균).
|
# 표에 없거나 안 준 암질(풍화암·암) — **원문이 정한 갈래**로만 선다(9-4-1 [주]① 평균).
|
||||||
price_code = f"{price_code}#{default[0]}"
|
price_code = f"{price_code}#{default[0]}"
|
||||||
@@ -444,6 +462,8 @@ def _leaf_row(
|
|||||||
row.add_note(
|
row.add_note(
|
||||||
"unit_price_krw", "일위대가가 아직 없습니다 — 금액을 0 으로 때우지 않습니다."
|
"unit_price_krw", "일위대가가 아직 없습니다 — 금액을 0 으로 때우지 않습니다."
|
||||||
)
|
)
|
||||||
|
if form_judgment_note(node.code): # 사람이 가른 표 형태 까닭(10-A ⑭)
|
||||||
|
row.add_note("unit_price_krw", form_judgment_note(node.code))
|
||||||
reason = "일위대가 없음"
|
reason = "일위대가 없음"
|
||||||
# 관경이 표 밖이면 **무엇을 정해야 하는지**까지 가리킨다.
|
# 관경이 표 밖이면 **무엇을 정해야 하는지**까지 가리킨다.
|
||||||
diameter_note = pipe_diameter_note(node.code, item.variant_value) if children else ""
|
diameter_note = pipe_diameter_note(node.code, item.variant_value) if children else ""
|
||||||
@@ -595,6 +615,7 @@ _PENDING_FORMULA: dict[str, str] = {}
|
|||||||
|
|
||||||
|
|
||||||
from B09_Estimation.B09_Estimation_KnownGaps import ( # noqa: E402
|
from B09_Estimation.B09_Estimation_KnownGaps import ( # noqa: E402
|
||||||
|
form_judgment_note,
|
||||||
known_gap_note,
|
known_gap_note,
|
||||||
pipe_diameter_note,
|
pipe_diameter_note,
|
||||||
)
|
)
|
||||||
@@ -610,71 +631,6 @@ def pending_formula_note(code: str | None) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _material_row(
|
|
||||||
material: HandoffMaterial, result: BillResult, manual: dict | None = None
|
|
||||||
) -> BillRow:
|
|
||||||
"""자재 한 줄. 공급 구분이 안 갈렸으면 **어느 쪽에도 안 넣는다**.
|
|
||||||
|
|
||||||
`manual` — 「자재 단가」 수동 단가(키 「이름 규격」). 사급 줄에 값이 있으면 빠진 목록에 안 올림
|
|
||||||
(금액은 본체 「자재(사급)」 줄이 셈 — `BillOfQuantities_Materials`).
|
|
||||||
"""
|
|
||||||
row = BillRow(
|
|
||||||
item_no="",
|
|
||||||
level=1,
|
|
||||||
code=None,
|
|
||||||
name=material.material_name,
|
|
||||||
spec=material.spec,
|
|
||||||
unit=material.unit,
|
|
||||||
quantity=material.total_amount,
|
|
||||||
)
|
|
||||||
# 할증 사유는 **수량**에 닿는다 — 할증이 곱해진 뒤의 수량이기 때문이다.
|
|
||||||
row.add_note("quantity", material.surcharge_note)
|
|
||||||
if material.supply_type == SUPPLY_UNKNOWN:
|
|
||||||
row.add_note(
|
|
||||||
"", "관급·사급이 안 갈렸습니다 — 관급자재대에도, 도급 재료비에도 넣지 않습니다."
|
|
||||||
)
|
|
||||||
result.missing.append(
|
|
||||||
{
|
|
||||||
"name": material.display_name,
|
|
||||||
"unit": material.unit,
|
|
||||||
"quantity": str(material.total_amount),
|
|
||||||
"reason": "공급 구분 미정(unknown)",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return row
|
|
||||||
# ⚠ **관급을 「사급」이라 적으면 안 된다** (2026-09-08 메인 창 실측 — 물구멍·야면석이
|
|
||||||
# `owner_supplied` 인데 「사급 자재 단가 미확보」로 뜨고 있었다). 갈래마다 **가는 자리도
|
|
||||||
# 원천도 다르다** — 관급은 총원가 밖 관급자재대(나라장터), 사급은 도급 재료비(물가지).
|
|
||||||
if material.supply_type == SUPPLY_OWNER:
|
|
||||||
row.add_note(
|
|
||||||
"unit_price_krw",
|
|
||||||
"관급 자재 단가 미확보 — 나라장터 목록에 이 품목이 없습니다. "
|
|
||||||
"관급자재대(총원가 밖 별도 표기)로 갑니다.",
|
|
||||||
)
|
|
||||||
reason = "관급 자재 단가 없음"
|
|
||||||
elif f"{material.material_name} {material.spec}".strip() in (manual or {}):
|
|
||||||
row.add_note(
|
|
||||||
"unit_price_krw", "⚠ 사급 자재 수동 단가(미확정) — 본체 「자재(사급)」 줄로 섬"
|
|
||||||
)
|
|
||||||
return row
|
|
||||||
else:
|
|
||||||
row.add_note(
|
|
||||||
"unit_price_krw", "사급 자재 단가 미확보 — 「자재 단가」 탭에서 수동 입력 대기."
|
|
||||||
)
|
|
||||||
reason = "사급 자재 단가 없음(미결 No.18)"
|
|
||||||
|
|
||||||
result.missing.append(
|
|
||||||
{
|
|
||||||
"name": material.display_name,
|
|
||||||
"unit": material.unit,
|
|
||||||
"quantity": str(material.total_amount),
|
|
||||||
"reason": reason,
|
|
||||||
"supply_type": material.supply_type,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return row
|
|
||||||
|
|
||||||
|
|
||||||
#: 같은 단위의 다른 표기 — 표기만 다르고 뜻이 같은 것을 「다르다」고 하면 멀쩡한 줄이 멈춘다.
|
#: 같은 단위의 다른 표기 — 표기만 다르고 뜻이 같은 것을 「다르다」고 하면 멀쩡한 줄이 멈춘다.
|
||||||
_UNIT_ALIASES = {
|
_UNIT_ALIASES = {
|
||||||
"㎥": "m3",
|
"㎥": "m3",
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
"""B09 원가계산 — **2장 소요재료·기계손료 표를 읽는 한 벌** (2026-09-15 브레인 ㉡ · 판정 ①②③).
|
||||||
|
|
||||||
|
산림품셈 2장은 소형 장비(체인톱·예취기·윈치·천공기 …)의 1대 1일 소모(연료·잡품·오일)와 손료계수를
|
||||||
|
표로 주고, 각 공종 표는 **인원**만 적음(「벌목부」·「특별인부 (체인톱 사용)」). 대수 규칙이 인원에 붙어
|
||||||
|
장비 몫을 세우는데 그 길이 없어 공종이 인력만으로 싸게 서 있었음(㉯ 셈 7 · 4-2-2 는 부모 표에만 장비 말).
|
||||||
|
|
||||||
|
장비 한 벌(`Equipment`) = 소요재료 표 줄 · 손료 표 · 고르는 줄(체인오일 일반/친환경) · 쓰는 공종과 인원 줄
|
||||||
|
값은 표에서 읽음(연료 ℓ/대/일 · 잡품 % · 오일 ℓ · 손료계수) — 여기엔 **어느 줄을 읽을지**만 적음
|
||||||
|
`X-<AR-X>` 1대 1일 호표 = 연료 × ℓ + 잡품(주연료비 %) + 손료(구입가 × 계수 · 구입가가 들면)
|
||||||
|
공종 제목에 장비 호표 × 인원 줄 수량(대수 = 인원 × 100%) · 고르는 오일은 넣은 쪽 하나 × 인원 × ℓ
|
||||||
|
|
||||||
|
⚠ 인원 줄은 **표가 이름으로 밝힌 줄만**(③) — 「비슷한 인원 줄」로 넓히지 않음.
|
||||||
|
⚠ 구입가·오일 단가는 카탈로그가 없어 「자재 단가」 칸 + 사유(②) · 휘발유는 유가 판으로 바로 섬.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Equipment:
|
||||||
|
key: str
|
||||||
|
machine: str # AR-X — 1대 1일 호표
|
||||||
|
price: str # AR-M — 구입가(손료 밑수) 칸
|
||||||
|
fuel: tuple[str, str, str] # (2-1 공종, 표, 주연료 줄 이름)
|
||||||
|
loss: tuple[str, str] # (2-2 공종, 표)
|
||||||
|
choices: tuple[tuple[str, str], ...] = () # 넣은 쪽 하나 — (AR-M, 2-1 표 줄 이름)
|
||||||
|
users: dict[str, str] = field(default_factory=dict) # 공종 → 표가 밝힌 인원 줄 이름
|
||||||
|
basis: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
EQUIPMENTS: tuple[Equipment, ...] = (
|
||||||
|
Equipment(
|
||||||
|
key="체인톱",
|
||||||
|
machine="AR-X-61d1681d",
|
||||||
|
price="AR-M-5649cf3f",
|
||||||
|
fuel=("FP-02-01-01", "F0042", "보통휘발유 (주연료)"),
|
||||||
|
loss=("FP-02-02-01", "F0064"),
|
||||||
|
choices=(
|
||||||
|
("AR-M-fa7fbf6d", "체인오일 (일반오일)"),
|
||||||
|
("AR-M-de2fe662", "체인오일 (친환경오일)"),
|
||||||
|
),
|
||||||
|
# 2-1-1 [주]① 「숲가꾸기(작업로설치, 어린나무가꾸기, 단목베기) 및 수확베기, 병해충방제」 중
|
||||||
|
# 지금 제목이 서는 둘 · 표가 이름으로 밝힌 인원 줄(2026-09-15 브레인 ③).
|
||||||
|
users={"FP-04-02-02": "벌목부", "FP-06-05": "특별인부 (체인톱 사용)"},
|
||||||
|
basis="산림품셈 2-1-1 「체인톱 대수는 산출된 벌목부 또는 특별인부의 100% 적용」 · 2-2-1 손료",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AreaSet:
|
||||||
|
"""면적·일 꼴 — 표의 「160ha당」 이 1일 작업량인 공종(8-6-1 유인헬기 · 2026-09-15 브레인 ①③)."""
|
||||||
|
|
||||||
|
work_item: str
|
||||||
|
machine: Equipment # 1대 1일 호표(양수기) — 갈래 면적으로 나눔
|
||||||
|
per_area: tuple[tuple[str, str], ...] = () # (AR-M, 2-1 표 줄 이름) — 「20ha당 1개」 를 읽음
|
||||||
|
per_day: tuple[tuple[str, str], ...] = () # (AR-M, 사유 이름) — 1일 1대 값 ÷ 갈래 면적
|
||||||
|
|
||||||
|
|
||||||
|
AREA_SETS: tuple[AreaSet, ...] = (
|
||||||
|
AreaSet(
|
||||||
|
work_item="FP-08-06-01",
|
||||||
|
machine=Equipment(
|
||||||
|
key="양수기",
|
||||||
|
machine="AR-X-ea09a419",
|
||||||
|
price="AR-M-cfc70216",
|
||||||
|
fuel=("FP-02-01-08", "F0058", "휘발유 (양수기)"),
|
||||||
|
loss=("FP-02-02-06", "F0069"),
|
||||||
|
basis="산림품셈 2-1-8 유인 헬기(160ha당) · [주]① 대형헬기도 휘발유 10ℓ · 2-2-6 양수기 손료",
|
||||||
|
),
|
||||||
|
per_area=(("AR-M-a79b4d20", "깃 발"),),
|
||||||
|
per_day=(("AR-M-08db9ecc", "유인헬기 임차료(1일)"),),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
#: 막힌 채 사유만 보태는 공종(밑수 없음 — 성분을 얹으면 밑수가 서는 날 두 번 셈 · 브레인 ②).
|
||||||
|
BLOCKED_NOTES: dict[str, tuple[str, ...]] = {
|
||||||
|
"FP-08-06-03": (
|
||||||
|
"경유(차량살포) 1.3ℓ/ha · 잡품 5% · 동력분무기 45HP 손료 0.0084 — 인력 표의 1일 작업량이 원문에 없음"
|
||||||
|
"(15ha 는 연료 설명) → 밑수가 없어 안 붙임",
|
||||||
|
"1톤 방제차량은 건설품셈 적산기준에 준하나 카탈로그에 1톤 트럭이 없음(덤프 2.5톤~·크레인 2톤~)",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
_RE_AREA = re.compile(r"\(\s*(\d[\d,]*(?:\.\d+)?)\s*ha\s*당\s*\)")
|
||||||
|
_RE_PER_AREA = re.compile(r"(\d+(?:\.\d+)?)\s*ha\s*당\s*(\d+(?:\.\d+)?)\s*개")
|
||||||
|
|
||||||
|
_RE_NUMBER = re.compile(r"\d+(?:\.\d+)?")
|
||||||
|
|
||||||
|
|
||||||
|
def _tight(text: Any) -> str:
|
||||||
|
return re.sub(r"\s", "", str(text or ""))
|
||||||
|
|
||||||
|
|
||||||
|
def _table_rows(nodes: dict[str, dict[str, Any]], code: str, table_id: str) -> list[list[str]]:
|
||||||
|
node = nodes.get(code) or {}
|
||||||
|
table = next((t for t in node.get("tables") or [] if t.get("pum_table_id") == table_id), {})
|
||||||
|
return [[str(c) for c in row] for row in table.get("raw_row") or []]
|
||||||
|
|
||||||
|
|
||||||
|
def _row_numbers(rows: list[list[str]], name: str) -> list[Decimal]:
|
||||||
|
"""그 이름 줄의 수 칸들 — 이름 칸 뒤에서 차례로(빈 칸 건너뜀)."""
|
||||||
|
row = next((r for r in rows if r and _tight(r[0]) == _tight(name)), None)
|
||||||
|
if row is None:
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
Decimal(m.group()) for c in row[1:] if (m := _RE_NUMBER.fullmatch(_tight(c).rstrip("%")))
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _fuel_values(rows: list[list[str]], name: str) -> tuple[Decimal, Decimal] | None:
|
||||||
|
"""(주연료 ℓ, 잡품 %) — ℓ 은 이름 다음 칸의 첫 수 · % 는 「%」 가 붙은 칸의 수(「주재료비의 95%」)."""
|
||||||
|
row = next((r for r in rows if r and _tight(r[0]) == _tight(name)), None)
|
||||||
|
if row is None or len(row) < 2:
|
||||||
|
return None
|
||||||
|
liters = _RE_NUMBER.search(row[1])
|
||||||
|
misc = next((m for c in row[2:] if "%" in c and (m := _RE_NUMBER.search(c))), None)
|
||||||
|
if liters is None or misc is None:
|
||||||
|
return None
|
||||||
|
return Decimal(liters.group()), Decimal(misc.group())
|
||||||
|
|
||||||
|
|
||||||
|
def _fuel_title(book: Any, kind: str, fuel_region: str | None) -> str:
|
||||||
|
from B09_Estimation.B09_Estimation_MachineOperating import load_fuel_price
|
||||||
|
from B09_Estimation.B09_Estimation_PriceBook import PriceKind, PriceTitle
|
||||||
|
from B09_Estimation.B09_Estimation_UnitPrice import FUEL_CODE_PREFIX, _slots
|
||||||
|
|
||||||
|
code = f"{FUEL_CODE_PREFIX}{kind}"
|
||||||
|
if code not in book.titles:
|
||||||
|
price, meta = load_fuel_price(region=fuel_region, kind=kind)
|
||||||
|
spec = f"{meta.get('region_name')} 공시가" if meta.get("region_name") else "전국 공시가"
|
||||||
|
book.add_title(PriceTitle(code, PriceKind.MATERIAL, kind, spec, "L", slots=_slots(price)))
|
||||||
|
return code
|
||||||
|
|
||||||
|
|
||||||
|
def _machine_title(build: Any, nodes: dict, eq: Equipment, fuel_region: str | None) -> list[str]:
|
||||||
|
"""`X-<AR-X>` 1대 1일 — 표에서 연료·잡품·손료계수를 읽음. 돌려주는 값 = 못 붙은 사유들."""
|
||||||
|
from B09_Estimation.B09_Estimation_PriceBook import PriceDetail, PriceKind, PriceTitle
|
||||||
|
from B09_Estimation.B09_Estimation_UnitPrice import _misc_row, _slots
|
||||||
|
|
||||||
|
book = build.book
|
||||||
|
code = f"X-{eq.machine}"
|
||||||
|
fuel = _fuel_values(_table_rows(nodes, eq.fuel[0], eq.fuel[1]), eq.fuel[2])
|
||||||
|
loss_rows = _table_rows(nodes, *eq.loss)
|
||||||
|
loss = _row_numbers(loss_rows, loss_rows[0][0]) if loss_rows else []
|
||||||
|
if fuel is None or not loss:
|
||||||
|
return [f"{eq.key} — 2장 표 칸이 달라져 장비 몫을 못 읽음"]
|
||||||
|
kind = "휘발유" if "휘발유" in eq.fuel[2] else "경유"
|
||||||
|
liters, misc = fuel
|
||||||
|
if code in book.titles:
|
||||||
|
return [] if eq.price in book.titles else [f"{eq.key}가격 — 손료({loss[0]} × 구입가) 칸"]
|
||||||
|
book.add_title(PriceTitle(code, PriceKind.MACHINE_HOURLY, eq.key, "1대 1일", "대·일"))
|
||||||
|
book.add_detail(
|
||||||
|
PriceDetail(code, _fuel_title(book, kind, fuel_region), liters, note=f"주연료 {kind}")
|
||||||
|
)
|
||||||
|
book.add_detail(_misc_row(code, misc))
|
||||||
|
reasons = []
|
||||||
|
if eq.price in book.titles:
|
||||||
|
base = f"S-{eq.machine}"
|
||||||
|
price = book.titles[eq.price].slots[-1]
|
||||||
|
book.add_title(
|
||||||
|
PriceTitle(
|
||||||
|
base, PriceKind.MACHINE_BASE, eq.key, "손료", "대·일", slots=_slots(price * loss[0])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
book.add_detail(PriceDetail(code, base, Decimal(1), note=f"손료 = 구입가 × {loss[0]}"))
|
||||||
|
else:
|
||||||
|
reasons.append(f"{eq.key}가격 — 손료({loss[0]} × 구입가) 칸 · 「자재 단가」 에 넣으면 붙음")
|
||||||
|
return reasons
|
||||||
|
|
||||||
|
|
||||||
|
def attach_consumables(
|
||||||
|
build: Any, nodes: dict[str, dict[str, Any]], fuel_region: str | None = None
|
||||||
|
) -> None:
|
||||||
|
"""장비마다 1대 1일 호표를 세우고, 쓰는 공종 제목에 인원 줄 수량만큼 붙임."""
|
||||||
|
from B09_Estimation.B09_Estimation_PriceBook import PriceDetail
|
||||||
|
|
||||||
|
book = build.book
|
||||||
|
for eq in EQUIPMENTS:
|
||||||
|
for material in (eq.price, *(c for c, _ in eq.choices)):
|
||||||
|
uses = build.material_uses.setdefault(material, [])
|
||||||
|
uses.extend(c for c in eq.users if c not in uses)
|
||||||
|
machine_reasons = _machine_title(build, nodes, eq, fuel_region)
|
||||||
|
fuel_rows = _table_rows(nodes, eq.fuel[0], eq.fuel[1])
|
||||||
|
picked = [(c, name) for c, name in eq.choices if c in book.titles]
|
||||||
|
for work_item, user_row in eq.users.items():
|
||||||
|
node_rows = [
|
||||||
|
r
|
||||||
|
for t in (nodes.get(work_item) or {}).get("tables") or []
|
||||||
|
for r in t.get("raw_row") or []
|
||||||
|
]
|
||||||
|
# 이름 칸이 뭉친 표(「벌목부 보통인부」)도 낱말로 봄 — 그 이름이 없으면 넓히지 않음(③).
|
||||||
|
if not any(
|
||||||
|
r and (_tight(r[0]) == _tight(user_row) or user_row in str(r[0]).split())
|
||||||
|
for r in node_rows
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
labor_name = _tight(user_row).split("(")[0]
|
||||||
|
reasons = list(machine_reasons)
|
||||||
|
for title in [
|
||||||
|
t for t in book.titles if t == f"B-{work_item}" or t.startswith(f"B-{work_item}#")
|
||||||
|
]:
|
||||||
|
if f"X-{eq.machine}" not in book.titles:
|
||||||
|
break
|
||||||
|
users = [
|
||||||
|
r
|
||||||
|
for r in book.details.get(title, [])
|
||||||
|
if book.titles.get(r.ref_code)
|
||||||
|
and _tight(book.titles[r.ref_code].name) == labor_name
|
||||||
|
]
|
||||||
|
if not users:
|
||||||
|
continue
|
||||||
|
count = users[0].quantity
|
||||||
|
note = f"{eq.key} {count}대(인원 「{user_row}」 × 100%) — {eq.basis}"
|
||||||
|
book.add_detail(PriceDetail(title, f"X-{eq.machine}", count, note=note))
|
||||||
|
if len(picked) == 1:
|
||||||
|
code, name = picked[0]
|
||||||
|
liters = _row_numbers(fuel_rows, name)[0]
|
||||||
|
book.add_detail(
|
||||||
|
PriceDetail(title, code, count * liters, note=f"{name} {liters}ℓ/대/일")
|
||||||
|
)
|
||||||
|
if len(picked) > 1:
|
||||||
|
reasons.append(
|
||||||
|
f"{eq.choices[0][1]}·{eq.choices[1][1]} 가 둘 다 들어옴 — 하나만 넣을 것"
|
||||||
|
)
|
||||||
|
elif not picked and eq.choices:
|
||||||
|
reasons.append(
|
||||||
|
f"{' 또는 '.join(name for _, name in eq.choices)} — 시중가격 칸(설계자 선택) · 넣은 쪽이 붙음"
|
||||||
|
)
|
||||||
|
labels = build.unattached.setdefault(work_item, [])
|
||||||
|
labels.extend(r for r in reasons if r not in labels)
|
||||||
|
for area in AREA_SETS:
|
||||||
|
_attach_area(build, nodes, area, fuel_region)
|
||||||
|
for code, notes in BLOCKED_NOTES.items():
|
||||||
|
labels = build.unattached.setdefault(code, [])
|
||||||
|
labels.extend(n for n in notes if n not in labels)
|
||||||
|
|
||||||
|
|
||||||
|
def _attach_area(build: Any, nodes: dict, area: AreaSet, fuel_region: str | None) -> None:
|
||||||
|
"""갈래 이름의 「(Nha당)」 = 1일 작업량 — 1일 몫(양수기·임차료)은 ÷ N · 「20ha당 1개」 는 ha당."""
|
||||||
|
from B09_Estimation.B09_Estimation_PriceBook import PriceDetail
|
||||||
|
|
||||||
|
book = build.book
|
||||||
|
eq = area.machine
|
||||||
|
for material in (eq.price, *(c for c, _ in area.per_area), *(c for c, _ in area.per_day)):
|
||||||
|
uses = build.material_uses.setdefault(material, [])
|
||||||
|
if area.work_item not in uses:
|
||||||
|
uses.append(area.work_item)
|
||||||
|
reasons = _machine_title(build, nodes, eq, fuel_region)
|
||||||
|
rows = _table_rows(nodes, eq.fuel[0], eq.fuel[1])
|
||||||
|
for title in [t for t in book.titles if t.startswith(f"B-{area.work_item}#")]:
|
||||||
|
found = _RE_AREA.search(title)
|
||||||
|
if found is None or f"X-{eq.machine}" not in book.titles:
|
||||||
|
continue
|
||||||
|
hectares = Decimal(found.group(1).replace(",", ""))
|
||||||
|
per_day = Decimal(1) / hectares
|
||||||
|
note = f"1일 1대 ÷ 1일 살포면적 {hectares}ha — {eq.basis}"
|
||||||
|
book.add_detail(PriceDetail(title, f"X-{eq.machine}", per_day, note=note))
|
||||||
|
for code, name in area.per_area:
|
||||||
|
row = next((r for r in rows if r and _tight(r[0]) == _tight(name)), [])
|
||||||
|
rule = next((m for c in row if (m := _RE_PER_AREA.search(c))), None)
|
||||||
|
if rule is None:
|
||||||
|
reasons.append(f"{_tight(name)} — 표의 「N ha당 N개」 를 못 읽음")
|
||||||
|
elif code in book.titles:
|
||||||
|
amount = Decimal(rule.group(2)) / Decimal(rule.group(1))
|
||||||
|
book.add_detail(
|
||||||
|
PriceDetail(title, code, amount, note=f"{_tight(name)} {rule.group(0)}")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
reasons.append(
|
||||||
|
f"{_tight(name)} — 단가 칸 · 넣으면 {rule.group(0)} 로 붙음(2-1-8 [주]③)"
|
||||||
|
)
|
||||||
|
for code, name in area.per_day:
|
||||||
|
if code in book.titles:
|
||||||
|
book.add_detail(PriceDetail(title, code, per_day, note=f"{name} ÷ {hectares}ha"))
|
||||||
|
else:
|
||||||
|
reasons.append(
|
||||||
|
f"{name} — 견적 칸 · 넣으면 ÷ 1일 살포면적으로 붙음(품셈에 사용료 표 없음)"
|
||||||
|
)
|
||||||
|
labels = build.unattached.setdefault(area.work_item, [])
|
||||||
|
labels.extend(r for r in reasons if r not in labels)
|
||||||
@@ -64,7 +64,8 @@ FIELD_HINTS: dict[str, str] = {
|
|||||||
"자동 = 요율 데이터 적용 하한(추정금액 1억 이상)일 때 적용 · STmate 의 토목·준설·건축·기타"
|
"자동 = 요율 데이터 적용 하한(추정금액 1억 이상)일 때 적용 · STmate 의 토목·준설·건축·기타"
|
||||||
" 구분은 현행 제비율(2026-04-13)에서 율이 같아(2.3%) 칸으로 두지 않음"
|
" 구분은 현행 제비율(2026-04-13)에서 율이 같아(2.3%) 칸으로 두지 않음"
|
||||||
),
|
),
|
||||||
"overhead_class": "임도가 (주)공사·전문공사 어느 쪽인지 정한 규정 없음 — 기본 (주)공사",
|
# 10-A ② 문구 못박음(2026-09-14 사용자 확정 — SW 규칙).
|
||||||
|
"overhead_class": "임도가 어느 쪽인지 규정이 없어 (주)공사 기본 · 칸에서 바꿀 수 있음",
|
||||||
"cut_basis": (
|
"cut_basis": (
|
||||||
"기본 「총공사비 1,000원 미만 버림」 — 근거: 산림청고시 제2025-82호 「금액의 단위표준」"
|
"기본 「총공사비 1,000원 미만 버림」 — 근거: 산림청고시 제2025-82호 「금액의 단위표준」"
|
||||||
"(설계서의 총액 · 원 · 1,000 · 미만버림) · 실무 6건이 여섯 다 000 으로 끝남 ·"
|
"(설계서의 총액 · 원 · 1,000 · 미만버림) · 실무 6건이 여섯 다 000 으로 끝남 ·"
|
||||||
@@ -99,7 +100,8 @@ FIELD_HINTS: dict[str, str] = {
|
|||||||
"waste_placement": (
|
"waste_placement": (
|
||||||
"법 문언(기본): 예정가격작성기준 제19조③18호 — 폐기물처리비는 경비라 일반관리비·이윤"
|
"법 문언(기본): 예정가격작성기준 제19조③18호 — 폐기물처리비는 경비라 일반관리비·이윤"
|
||||||
" 밑수에 듦 · 실무 관행: 울진소광 원가계산서(이윤 뒤·총원가 안)·임목폐기물처리 실정보고"
|
" 밑수에 듦 · 실무 관행: 울진소광 원가계산서(이윤 뒤·총원가 안)·임목폐기물처리 실정보고"
|
||||||
"(공급가액 뒤)는 승률 밖 — 금액이 크게 갈려 설계자가 고름"
|
"(공급가액 뒤)는 승률 밖 — 금액이 크게 갈려 설계자가 고름 ·"
|
||||||
|
" 어느 자리든 법정경비 밑수(직접공사비·노무비)에는 안 넣음"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
_GRADE_OPTIONS = [
|
_GRADE_OPTIONS = [
|
||||||
|
|||||||
@@ -3,10 +3,13 @@
|
|||||||
근거: STmate 분석 `12_원가계산_4형식과_RATE표_복원.md` §4~6 (DFM 복원 서식) ·
|
근거: STmate 분석 `12_원가계산_4형식과_RATE표_복원.md` §4~6 (DFM 복원 서식) ·
|
||||||
`35_형식과_단계의_공통과_차이.md` §1·§2 (공통 사슬 · 형식별 차이 · 12번 정정 반영).
|
`35_형식과_단계의_공통과_차이.md` §1·§2 (공통 사슬 · 형식별 차이 · 12번 정정 반영).
|
||||||
|
|
||||||
⚠ **금액을 안 낸다** — 요율 데이터가 없다(2026-09-14 확인: 수공 세부 경비 율표 `RateB` ·
|
⚠ **금액을 안 낸다** — 2026-09-14 요율표 셋 결론(PLAN 7장): 실적형 제비율 `RATE_RA`·`RATE_RA1` 은
|
||||||
표준시장단가 제비율 `RATE_RA`·`RATE_RA1`·실적 안전표 `An_A`). 줄마다 무엇이 막혔는지만 적는다.
|
**임도 미적용**(100억 미만 · 예정가격작성기준 §37②) · 수공 세부 경비 `RateB` 는 **법정 요율 없음**
|
||||||
|
(업체 실측 대상 §34① — 설계자 입력 칸) · 실적 안전표 `An_A` 는 고시 편입(제4조·별표1)이나 실적형
|
||||||
|
자체가 임도 미적용. 줄마다 무엇이 막혔는지만 적는다.
|
||||||
**0 원으로 채우지 않는다** — 0 원은 「없다」가 아니라 「공짜」로 읽혀 총액이 조용히 작아진다.
|
**0 원으로 채우지 않는다** — 0 원은 「없다」가 아니라 「공짜」로 읽혀 총액이 조용히 작아진다.
|
||||||
⚠ 35번 §5 미확인(실적형 이윤 밑수 · 실적수공 안전관리비 존재)은 「확인 대기」로 세운다.
|
⚠ 35번 §5 미확인은 「확인 대기」로 세운다 — 지금 남은 것은 **실적형 이윤 밑수** 하나다.
|
||||||
|
실적수공 안전관리비는 2026-09-14 코덱스가 STmate 원문에서 **있음**을 확인해 별도내역으로 내렸다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -16,12 +19,20 @@ from typing import Any
|
|||||||
#: 줄 상태 — 화면이 그대로 보인다.
|
#: 줄 상태 — 화면이 그대로 보인다.
|
||||||
COMMON = "공통 요율 — 일반형식과 같은 표(형식 전체가 서면 금액이 섬)"
|
COMMON = "공통 요율 — 일반형식과 같은 표(형식 전체가 서면 금액이 섬)"
|
||||||
SEPARATE = "별도내역 — 요율이 아니라 따로 산출한 금액"
|
SEPARATE = "별도내역 — 요율이 아니라 따로 산출한 금액"
|
||||||
RATE_B = "요율표 미확보 — 수공 세부 경비 율표(STmate RateB)"
|
RATE_B = (
|
||||||
RATE_RA = "요율표 미확보 — 표준시장단가(실적공사비) 제비율(STmate RATE_RA·RATE_RA1)"
|
"법정 요율 없음 — 값을 넣어 주십시오(업체 실측 대상 · 예정가격작성기준 §34①) · 수공 세부 경비"
|
||||||
RATE_AN_A = "요율표 미확보 — 실적 산업안전보건관리비 표(STmate An_A·An_A1)"
|
"(STmate RateB) · 입력 칸은 수공형식 금액 셈과 함께 섬"
|
||||||
|
)
|
||||||
|
RATE_RA = (
|
||||||
|
"임도 미적용 — 100억 미만(예정가격작성기준 §37②) · 표준시장단가(실적공사비) 제비율"
|
||||||
|
"(STmate RATE_RA·RATE_RA1)"
|
||||||
|
)
|
||||||
|
RATE_AN_A = (
|
||||||
|
"임도 미적용 — 실적형 형식 전체가 100억 미만 임도에 안 씀(§37②) · 요율 자체는 고시 편입"
|
||||||
|
"(건설업 산업안전보건관리비 계상 및 사용기준 제4조·별표1)"
|
||||||
|
)
|
||||||
TOTAL = "합계 — 위 줄이 다 서야 섬"
|
TOTAL = "합계 — 위 줄이 다 서야 섬"
|
||||||
PENDING_PROFIT = "확인 대기 — 실적형 이윤 밑수가 복원식과 화면 문구가 충돌(35번 §5-1)"
|
PENDING_PROFIT = "확인 대기 — 실적형 이윤 밑수가 복원식과 화면 문구가 충돌(35번 §5-1)"
|
||||||
PENDING_SAFETY = "확인 대기 — 실적수공에 안전관리비가 실제로 있는지 미확인(35번 §5-2)"
|
|
||||||
DIRECT = "설계내역서 합계에서 옴"
|
DIRECT = "설계내역서 합계에서 옴"
|
||||||
|
|
||||||
_SUGONG_EXPENSES = (
|
_SUGONG_EXPENSES = (
|
||||||
@@ -115,7 +126,8 @@ def _actual(sugong: bool) -> list[dict[str, Any]]:
|
|||||||
_r("6)", "연금보험료", "직접노무비 × 율", COMMON, 1),
|
_r("6)", "연금보험료", "직접노무비 × 율", COMMON, 1),
|
||||||
_r("7)", "퇴직공제부금비", "직접노무비 × 율", COMMON, 1),
|
_r("7)", "퇴직공제부금비", "직접노무비 × 율", COMMON, 1),
|
||||||
_r("8)", "산업안전보건관리비", "(직접공사비+도급자관급자재) × 율", RATE_AN_A, 1),
|
_r("8)", "산업안전보건관리비", "(직접공사비+도급자관급자재) × 율", RATE_AN_A, 1),
|
||||||
_r("9)", "안전관리비", "", PENDING_SAFETY if sugong else SEPARATE, 1),
|
# TA·SA 둘 다 별도내역 — SA 도 있음이 확인됨(2026-09-14 코덱스, 35번 §5-2 풀림).
|
||||||
|
_r("9)", "안전관리비", "", SEPARATE, 1),
|
||||||
_r("10)", "품질관리비", "", SEPARATE, 1),
|
_r("10)", "품질관리비", "", SEPARATE, 1),
|
||||||
]
|
]
|
||||||
if sugong:
|
if sugong:
|
||||||
|
|||||||
@@ -18,14 +18,12 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass, field, replace
|
from dataclasses import dataclass, field, replace
|
||||||
from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal
|
from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from B09_Estimation.B09_Estimation_Engine_Cost_Options import (
|
from B09_Estimation.B09_Estimation_Engine_Cost_Options import (
|
||||||
CUT_BASES,
|
CUT_BASES,
|
||||||
VAT_MODES,
|
VAT_MODES,
|
||||||
cut_gap,
|
cut_gap,
|
||||||
overhead_base,
|
overhead_base,
|
||||||
profit_cut,
|
|
||||||
vat_base,
|
vat_base,
|
||||||
)
|
)
|
||||||
from B09_Estimation.B09_Estimation_RateOverride import apply_overrides
|
from B09_Estimation.B09_Estimation_RateOverride import apply_overrides
|
||||||
@@ -46,9 +44,12 @@ from B09_Estimation.B09_Estimation_Statutory import (
|
|||||||
_ZERO = Decimal(0)
|
_ZERO = Decimal(0)
|
||||||
_HUNDRED = Decimal(100)
|
_HUNDRED = Decimal(100)
|
||||||
|
|
||||||
#: 기본으로 켜는 비목 = **그 해 요율 데이터에 있는 것 전부**.
|
# ⬇ 입력 한 벌은 `_Input` 으로 옮겼다(2026-09-15 700줄 분리) —
|
||||||
#: 사용자 확정(2026-09-07, PLAN 8-14): 실무 서류에 없다고 빼지 않는다.
|
# 쓰던 이름이 그대로 살게 다시 내보낸다.
|
||||||
DEFAULT_ITEMS = "ALL_AVAILABLE"
|
from B09_Estimation.B09_Estimation_Engine_Cost_Input import ( # noqa: E402,F401
|
||||||
|
DEFAULT_ITEMS,
|
||||||
|
CostInput,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def floor_won(value: Decimal) -> Decimal:
|
def floor_won(value: Decimal) -> Decimal:
|
||||||
@@ -61,119 +62,6 @@ def ceil_thousand(value: Decimal) -> Decimal:
|
|||||||
return (value / 1000).quantize(Decimal(1), rounding=ROUND_CEILING) * 1000
|
return (value / 1000).quantize(Decimal(1), rounding=ROUND_CEILING) * 1000
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class CostInput:
|
|
||||||
"""원가계산 입력. 금액은 전부 원 단위 `Decimal`."""
|
|
||||||
|
|
||||||
direct_material_krw: Decimal
|
|
||||||
direct_labor_krw: Decimal
|
|
||||||
direct_expense_krw: Decimal
|
|
||||||
indirect_material_krw: Decimal = _ZERO
|
|
||||||
|
|
||||||
#: 구간 판정용 공종·기간. `work_type` 값은 요율 데이터의 표기를 그대로 쓴다.
|
|
||||||
work_type_indirect_labor: str = "civil"
|
|
||||||
work_type_safety: str = "civil"
|
|
||||||
duration_days: int = 183
|
|
||||||
pension_year: int = 2026
|
|
||||||
|
|
||||||
#: 관급자재 — **순자재대**와 조달수수료를 나눠 받는다(순환 정의 방지, 원가계산_체계 §1).
|
|
||||||
#: ⚠ `owner_supplied_material_krw` 는 **수수료를 뺀 순자재대**다. 실무 서류의
|
|
||||||
#: 「관급자재대」는 이미 `순자재대 + 수수료` 를 천원 올림한 값이므로 그대로 넣으면 안 된다
|
|
||||||
#: (2026-09-07 실측 정정 — 울진 순자재대 69,474,220 + 수수료 375,160 = 69,849,380 →
|
|
||||||
#: 천원 올림 69,850,000). 안전관리비 관급항도 **순자재대만** 쓴다.
|
|
||||||
owner_supplied_material_krw: Decimal = _ZERO
|
|
||||||
procurement_fee_krw: Decimal = _ZERO
|
|
||||||
include_fee_in_owner_material_total: bool = True
|
|
||||||
|
|
||||||
#: 안전관리비 대상액에 들어가는 **도급자설치 관급금액**. None 이면 관급 전액.
|
|
||||||
owner_supplied_for_safety_krw: Decimal | None = None
|
|
||||||
#: 그 금액이 부가세 포함인가 — 포함이면 1.1 로 나눈다(규정: 부가세 제외 기준).
|
|
||||||
owner_supplied_includes_vat: bool = True
|
|
||||||
#: 규모 구간 판정에 쓸 **추정가격**. 주면 그 값으로 한 번만 판정한다.
|
|
||||||
#: 없으면 직접공사비를 씨앗으로 **반복 수렴**한다 (`calculate_cost` 참조).
|
|
||||||
#: 근거 — 국가계약법 시행령 제7조 1호 「공사계약의 경우에는 관급자재로 공급될
|
|
||||||
#: 부분의 가격을 제외한 금액」. 우리 계산의 그 값은 **총원가**(부가세 전, 관급 밖).
|
|
||||||
estimated_price_krw: Decimal | None = None
|
|
||||||
|
|
||||||
#: 이윤 수동 조정액 — 설계자 명시 입력일 때만. 프로그램이 스스로 채우지 않는다.
|
|
||||||
profit_adjustment_krw: Decimal = _ZERO
|
|
||||||
#: 조정액 줄 산식 칸 — 비면 「설계자 명시 입력」. 절사 자동보정이 제 이름을 적는 자리.
|
|
||||||
profit_adjustment_label: str = ""
|
|
||||||
|
|
||||||
#: 폐기물처리비 — 요율이 아니라 **실비**(수량 × 처리단가).
|
|
||||||
#: ⭐ 2026-09-14 판정 — **비목은 경비**(예정가격작성기준 제19조③18호)라 순공사원가에 들고
|
|
||||||
#: **일반관리비·이윤 밑수에 든다.** 빠지면 총액이 조용히 작아진다.
|
|
||||||
#: ⚠ 법정경비 밑수(직접공사비·노무비)에는 안 넣는다 — 그 밑수는 요율 데이터가 정한 합이다.
|
|
||||||
waste_disposal_krw: Decimal = _ZERO
|
|
||||||
#: 분리발주 — 켜면 공사와 따로 발주하는 용역이라 **총원가 밖**, 총공사비에만 더한다
|
|
||||||
#: (거창 원가계산서 `총공사비 = 도급액 + 관급자재대 + 폐기물처리비` 모양). 기본 꺼짐.
|
|
||||||
waste_separate_order: bool = False
|
|
||||||
#: 폐기물처리비 자리 — `expense`(기본 · 법 문언: 경비 · 일반관리비·이윤 밑수 안) ·
|
|
||||||
#: `after_profit`(실무 관행: 이윤 뒤 · 총원가 안 · 부가세 안 · 승률 밖 — 울진소광·실정보고).
|
|
||||||
#: 분리발주(`waste_separate_order`)가 켜지면 그쪽이 먼저(총원가 밖).
|
|
||||||
waste_placement: str = "expense"
|
|
||||||
|
|
||||||
#: ── 기준 입력 10·11·12 (규칙은 `Engine_Cost_Options`) — 기본값은 종전 계산과 같음 ──
|
|
||||||
#: 원가계산 형식 — 일반관리비 밑수가 형식마다 다름(지금은 `general` 만).
|
|
||||||
form: str = "general"
|
|
||||||
#: 10. 일반관리비 — 요율 표 이름((주)공사 / 전문공사).
|
|
||||||
overhead_class: str = "civil_landscape_industrial"
|
|
||||||
#: 일반 형식 일반관리비 밑수에 더하는 관리품목 자재대.
|
|
||||||
overhead_managed_material_krw: Decimal = _ZERO
|
|
||||||
#: 12. 부가세 방식(`VAT_MODES`) · 산림조합-면세품이면 면세품 금액.
|
|
||||||
vat_mode: str = "supply"
|
|
||||||
tax_exempt_material_krw: Decimal = _ZERO
|
|
||||||
#: 11. 절사 — `grand_total`(총공사비에서 조정) · `supply`(공급가액) · `none`·빈 값은 안 자름.
|
|
||||||
#: ⭐ 2026-09-14 **기본이 켜짐** — 근거 산림청고시 제2025-82호 「금액의 단위표준」
|
|
||||||
#: 「설계서의 총액 · 원 · 1,000 · 미만버림」.
|
|
||||||
#: 실무 6건이 여섯 다 `000` 으로 끝나는 것이 증거.
|
|
||||||
#: ⚠ 2026-09-08 「조용히 깎지 않음」 합의의 정신은 살린다 — 깎은 몫과
|
|
||||||
#: **까닭(고시 번호)** 을 이윤 조정액 줄과 결과 메모에 적고,
|
|
||||||
#: 설계자가 `none` 으로 **끌 수 있게** 둔다.
|
|
||||||
cut_basis: str = "grand_total"
|
|
||||||
cut_unit_krw: int = 1000
|
|
||||||
#: 4. 고용보험 등급 — `auto`(추정금액 구간) · `none`(없음) · `1`~`7`(등급 직접).
|
|
||||||
employment_insurance_grade: str = "auto"
|
|
||||||
#: 5. 퇴직공제부금비 — `auto`(추정금액 1억 이상) · `apply`(적용) · `none`(미적용).
|
|
||||||
#: ⚠ STmate 는 토목·준설·건축·기타로 가르나 현행 제비율(2026-04-13)은 공종 구분 없이 2.3% —
|
|
||||||
#: 율이 안 갈리는 구분은 칸으로 안 세움.
|
|
||||||
retirement_mutual_aid_mode: str = "auto"
|
|
||||||
#: 13. 사급비 위치 — `material`(재료비에 포함) 뿐. 사급은 이미 내역 재료비에 들어 오므로
|
|
||||||
#: 계산에 안 씀(실무 거창·영월 · STmate 기본). 다른 자리는 사급 금액 칸이 설 때 엶.
|
|
||||||
private_material_position: str = "material"
|
|
||||||
#: 14. 낙찰방식 — `not_comprehensive`(종합심사 외) · `comprehensive`(종합심사 대상) · `turnkey`.
|
|
||||||
#: 하도급대금 지급보증 요율 줄만 고름.
|
|
||||||
bid_method: str = "not_comprehensive"
|
|
||||||
#: 하도급대금 지급보증수수료 — `off`(기본) · `on`. 실무 여섯 건에 줄 없음 · 건설산업기본법
|
|
||||||
#: 제34조 대상이면 켬(「대상 아님」인지 「안 적음」인지는 실무로 못 가름).
|
|
||||||
subcontract_guarantee: str = "off"
|
|
||||||
#: 15. 이행보증 — `general`(일반계약: 추정가격 300억 이상만) ·
|
|
||||||
#: `lowest_price_tech`(최저가·기술제안: 규모 무관).
|
|
||||||
performance_guarantee_mode: str = "general"
|
|
||||||
#: 16. 적용기준 — `national`·`local`·`moi`. 계산에 안 씀: 현행 조달청 표 한 벌뿐 · 국가/지방
|
|
||||||
#: 구간 차이(예정가격작성기준 제20조 표)는 원문 이미지라 미확인 · 행자부 표 없음. 칸만 받음.
|
|
||||||
contract_law_basis: str = ""
|
|
||||||
#: 프로젝트 요율 덮어쓰기(`RateOverride` 한 줄씩, 사유 포함) — 발주처별 별도요율.
|
|
||||||
rate_overrides: tuple[dict[str, Any], ...] = ()
|
|
||||||
|
|
||||||
#: 환경보전비 공종 (`rate_environment.all_work_types` 의 값).
|
|
||||||
#: TODO(미결 PLAN 9-6): 임도가 「도로 0.9 %」인지 「기타 토목 0.8 %」인지 미확정.
|
|
||||||
#: 잠정 = 도로(0.9 %). 요율 데이터가 `pending` 을 달고 있어 결과 줄에 경고가 붙는다.
|
|
||||||
environment_work_type: str = "civil_road"
|
|
||||||
#: 건설기계대여대금 지급보증 공종.
|
|
||||||
equipment_guarantee_work_type: str = "civil_general"
|
|
||||||
#: 하도급대금 지급보증 — 30억 이상 구간이 공종으로 갈린다(토목·산업설비 / 건축).
|
|
||||||
subcontract_guarantee_variant: str = "integrated_civil_or_industrial"
|
|
||||||
|
|
||||||
#: 켤 비목. 기본은 「그 해 요율 데이터에 있는 것 전부」.
|
|
||||||
enabled_items: tuple[str, ...] | str = DEFAULT_ITEMS
|
|
||||||
|
|
||||||
#: 요율 데이터 파일명. **연도를 갈아끼우는 자리.**
|
|
||||||
rate_file_name: str = "rates_2026.json"
|
|
||||||
#: 매니페스트 밖 요율 파일(옛 연도 재현 검산 전용). 주면 이쪽이 우선.
|
|
||||||
rate_file_path: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CostLine:
|
class CostLine:
|
||||||
"""원가계산서 한 줄 — 화면이 「비목·금액·요율·산출근거」를 다 보이므로 넷을 다 든다."""
|
"""원가계산서 한 줄 — 화면이 「비목·금액·요율·산출근거」를 다 보이므로 넷을 다 든다."""
|
||||||
@@ -365,9 +253,9 @@ def calculate_cost(data: CostInput) -> CostResult:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
#: 절사 뒤 잔차 맞춤 반복 상한. 실무 6건은 **두 걸음 안에** 앉음
|
#: 절사 목표를 몇 칸까지 내려 볼 것인가. 못 밟는 1,000 배수를 만나면 한 칸 내린다 —
|
||||||
#: (다섯 건 한 걸음 · 울진 신설 두 걸음).
|
#: 실측(거창 꼴 40 자리)에서는 **한 칸이면 다 앉았다**. 여유로 셋까지 본다.
|
||||||
_CUT_MAX_PASSES = 3
|
_CUT_DESCENT_MAX = 3
|
||||||
#: 11. 절사 끔 — 고르개가 보내는 값. 빈 값도 같이 받는다(저장 안 된 옛 프로젝트).
|
#: 11. 절사 끔 — 고르개가 보내는 값. 빈 값도 같이 받는다(저장 안 된 옛 프로젝트).
|
||||||
CUT_OFF = ("", "none")
|
CUT_OFF = ("", "none")
|
||||||
|
|
||||||
@@ -383,30 +271,64 @@ def _calculate_with_scale(
|
|||||||
⚠ **걸음마다 ÷1.1 을 해야 한다**(2026-09-14 고침). 이윤을 1원 깎으면 부가세가 따라 줄어
|
⚠ **걸음마다 ÷1.1 을 해야 한다**(2026-09-14 고침). 이윤을 1원 깎으면 부가세가 따라 줄어
|
||||||
총공사비는 **1.1원** 줄므로, 잔차를 그대로 빼면 경계를 **지나쳐** 진동한다 —
|
총공사비는 **1.1원** 줄므로, 잔차를 그대로 빼면 경계를 **지나쳐** 진동한다 —
|
||||||
울진 신설 실측 `654 → 999 → 900 → 910 → 909 …` 로 안 앉았다. 늘 ÷1.1 하면 `654 → 999 → 0`.
|
울진 신설 실측 `654 → 999 → 900 → 910 → 909 …` 로 안 앉았다. 늘 ÷1.1 하면 `654 → 999 → 0`.
|
||||||
|
|
||||||
|
⚠ **못 밟는 배수가 있다**(2026-09-14 둘째 고침). 한 걸음 낙차가 1원일 때도 2원일 때도 있어
|
||||||
|
(부가세가 버림이라 열 걸음에 한 번쯤 2원) 어떤 1,000 배수는 **건너뛴다**. 어림으로 좇으면
|
||||||
|
지나쳐 놓고 못 돌아온다 — 거창 꼴 40 자리 중 16 이 끝자리 999 로 남았다. 그래서
|
||||||
|
**목표를 못 박고 가장 작은 보정액을 이분으로 찾는다**(총공사비는 보정액에 대해 비증가).
|
||||||
|
그 배수를 못 밟으면 **한 칸 아래 배수**로 목표를 내려 다시 찾는다(뒷받침일 뿐 — 실측 거창 꼴
|
||||||
|
40 자리에서는 한 칸도 안 내려갔다). 이윤은 늘 **가장 적게** 깎인다 — 실무 식(÷1.1 한 걸음)이
|
||||||
|
앉는 자리에서는 같은 값이 나온다(봉화 339 · 영월 632).
|
||||||
"""
|
"""
|
||||||
result = _calculate_once(data, dataset, scale, notes)
|
result = _calculate_once(data, dataset, scale, notes)
|
||||||
if data.cut_basis in CUT_OFF or data.cut_unit_krw <= 0:
|
if data.cut_basis in CUT_OFF or data.cut_unit_krw <= 0:
|
||||||
return result
|
return result
|
||||||
key = "grand_total" if data.cut_basis == "grand_total" else "total_cost"
|
key = "grand_total" if data.cut_basis == "grand_total" else "total_cost"
|
||||||
|
label = (
|
||||||
|
f"{CUT_BASES.get(data.cut_basis, data.cut_basis)} {data.cut_unit_krw:,}원 미만"
|
||||||
|
" 절사 자동보정(산림청고시 2025-82호)"
|
||||||
|
+ (" + 설계자 입력" if data.profit_adjustment_krw else "")
|
||||||
|
)
|
||||||
|
|
||||||
|
def attempt(extra: Decimal) -> CostResult:
|
||||||
|
"""이윤을 `extra` 만큼 더 깎아 한 번 셈. `0` 이면 안 자른 결과 그대로."""
|
||||||
|
if extra == 0:
|
||||||
|
return result
|
||||||
|
return _calculate_once(
|
||||||
|
replace(
|
||||||
|
data,
|
||||||
|
profit_adjustment_krw=data.profit_adjustment_krw + extra,
|
||||||
|
cut_basis="none",
|
||||||
|
profit_adjustment_label=label,
|
||||||
|
),
|
||||||
|
dataset,
|
||||||
|
scale,
|
||||||
|
notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
raw = result.totals[key]
|
||||||
|
target = raw - cut_gap(raw, data.cut_unit_krw)
|
||||||
automatic = _ZERO
|
automatic = _ZERO
|
||||||
gap = _ZERO
|
gap = _ZERO
|
||||||
for _ in range(_CUT_MAX_PASSES):
|
landed = result
|
||||||
gap = cut_gap(result.totals[key], data.cut_unit_krw)
|
for _ in range(_CUT_DESCENT_MAX):
|
||||||
|
# 목표 이하로 내려가는 **가장 작은** 보정액을 이분으로 찾는다. 한 걸음이 적어도 1원을
|
||||||
|
# 깎으므로 `raw - target` 이면 반드시 목표 아래로 간다.
|
||||||
|
low, high = _ZERO, raw - target
|
||||||
|
while low < high:
|
||||||
|
middle = (low + high) // 2
|
||||||
|
if attempt(middle).totals[key] <= target:
|
||||||
|
high = middle
|
||||||
|
else:
|
||||||
|
low = middle + 1
|
||||||
|
landed = attempt(low)
|
||||||
|
gap = landed.totals[key] - target
|
||||||
if gap == 0:
|
if gap == 0:
|
||||||
|
automatic = low
|
||||||
break
|
break
|
||||||
# 실무 식 — 총공사비 절사 + 부가세가 공급가액 비례면 ÷1.1(잔차 걸음도 같음).
|
# 지나쳤다 = 그 배수는 못 밟는 자리. 한 칸 아래를 노린다.
|
||||||
automatic += profit_cut(gap, data.cut_basis, data.vat_mode)
|
target -= data.cut_unit_krw
|
||||||
adjusted = replace(
|
result = landed
|
||||||
data,
|
|
||||||
profit_adjustment_krw=data.profit_adjustment_krw + automatic,
|
|
||||||
cut_basis="none",
|
|
||||||
profit_adjustment_label=(
|
|
||||||
f"{CUT_BASES.get(data.cut_basis, data.cut_basis)} {data.cut_unit_krw:,}원 미만"
|
|
||||||
" 절사 자동보정(산림청고시 2025-82호)"
|
|
||||||
+ (" + 설계자 입력" if data.profit_adjustment_krw else "")
|
|
||||||
),
|
|
||||||
)
|
|
||||||
result = _calculate_once(adjusted, dataset, scale, notes)
|
|
||||||
result.notes.append(
|
result.notes.append(
|
||||||
f"{CUT_BASES.get(data.cut_basis, data.cut_basis)} {data.cut_unit_krw:,}원 미만 절사 —"
|
f"{CUT_BASES.get(data.cut_basis, data.cut_basis)} {data.cut_unit_krw:,}원 미만 절사 —"
|
||||||
f" 이윤에서 {automatic:,}원 자동보정."
|
f" 이윤에서 {automatic:,}원 자동보정."
|
||||||
@@ -416,7 +338,7 @@ def _calculate_with_scale(
|
|||||||
if gap:
|
if gap:
|
||||||
# 조용히 남기지 않는다 — 안 앉았으면 끝자리가 남았다는 것을 화면에 보인다.
|
# 조용히 남기지 않는다 — 안 앉았으면 끝자리가 남았다는 것을 화면에 보인다.
|
||||||
result.notes.append(
|
result.notes.append(
|
||||||
f"⚠ 절사가 {_CUT_MAX_PASSES}걸음 안에 안 앉음 —"
|
f"⚠ 절사가 {_CUT_DESCENT_MAX}칸 안에 안 앉음 —"
|
||||||
f" 끝자리 {gap:,}원이 남음(설계자 확인 필요)"
|
f" 끝자리 {gap:,}원이 남음(설계자 확인 필요)"
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"""B09 원가계산 — ⑤ 공사원가계산서 **입력 한 벌**(`CostInput`).
|
||||||
|
|
||||||
|
`B09_Estimation_Engine_Cost` 가 700줄에 닿아 **그대로 옮겨 온 자리**다
|
||||||
|
(2026-09-15 · 순수 분리 — 글자 한 자 안 고쳤다). 계산 사슬은 그쪽에 그대로 있다.
|
||||||
|
|
||||||
|
⚠ 쓰던 자리가 안 바뀌도록 엔진이 다시 내보낸다 —
|
||||||
|
`from ...Engine_Cost import CostInput` 가 그대로 돈다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
_ZERO = Decimal(0)
|
||||||
|
|
||||||
|
#: 기본으로 켜는 비목 = **그 해 요율 데이터에 있는 것 전부**.
|
||||||
|
#: 사용자 확정(2026-09-07, PLAN 8-14): 실무 서류에 없다고 빼지 않는다.
|
||||||
|
DEFAULT_ITEMS = "ALL_AVAILABLE"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CostInput:
|
||||||
|
"""원가계산 입력. 금액은 전부 원 단위 `Decimal`."""
|
||||||
|
|
||||||
|
direct_material_krw: Decimal
|
||||||
|
direct_labor_krw: Decimal
|
||||||
|
direct_expense_krw: Decimal
|
||||||
|
indirect_material_krw: Decimal = _ZERO
|
||||||
|
|
||||||
|
#: 구간 판정용 공종·기간. `work_type` 값은 요율 데이터의 표기를 그대로 쓴다.
|
||||||
|
work_type_indirect_labor: str = "civil"
|
||||||
|
work_type_safety: str = "civil"
|
||||||
|
duration_days: int = 183
|
||||||
|
pension_year: int = 2026
|
||||||
|
|
||||||
|
#: 관급자재 — **순자재대**와 조달수수료를 나눠 받는다(순환 정의 방지, 원가계산_체계 §1).
|
||||||
|
#: ⚠ `owner_supplied_material_krw` 는 **수수료를 뺀 순자재대**다. 실무 서류의
|
||||||
|
#: 「관급자재대」는 이미 `순자재대 + 수수료` 를 천원 올림한 값이므로 그대로 넣으면 안 된다
|
||||||
|
#: (2026-09-07 실측 정정 — 울진 순자재대 69,474,220 + 수수료 375,160 = 69,849,380 →
|
||||||
|
#: 천원 올림 69,850,000). 안전관리비 관급항도 **순자재대만** 쓴다.
|
||||||
|
owner_supplied_material_krw: Decimal = _ZERO
|
||||||
|
procurement_fee_krw: Decimal = _ZERO
|
||||||
|
include_fee_in_owner_material_total: bool = True
|
||||||
|
|
||||||
|
#: 안전관리비 대상액에 들어가는 **도급자설치 관급금액**. None 이면 관급 전액.
|
||||||
|
owner_supplied_for_safety_krw: Decimal | None = None
|
||||||
|
#: 그 금액이 부가세 포함인가 — 포함이면 1.1 로 나눈다.
|
||||||
|
#: ⚠ 근거는 **실무**다 — 고시(산업안전보건관리비 계상 및 사용기준) 제4조① 단서는 「해당
|
||||||
|
#: 재료비를 **대상액에 포함**」까지만 적고 부가세를 말하지 않는다. ÷1.1 은 부가세 제외
|
||||||
|
#: 환산이며, 실무 원가계산서 **6건이 모두** 「관급재/1.1」로 적었다(2026-09-14 전수 확인).
|
||||||
|
owner_supplied_includes_vat: bool = True
|
||||||
|
#: 규모 구간 판정에 쓸 **추정가격**. 주면 그 값으로 한 번만 판정한다.
|
||||||
|
#: 없으면 직접공사비를 씨앗으로 **반복 수렴**한다 (`calculate_cost` 참조).
|
||||||
|
#: 근거 — 국가계약법 시행령 제7조 1호 「공사계약의 경우에는 관급자재로 공급될
|
||||||
|
#: 부분의 가격을 제외한 금액」. 우리 계산의 그 값은 **총원가**(부가세 전, 관급 밖).
|
||||||
|
estimated_price_krw: Decimal | None = None
|
||||||
|
|
||||||
|
#: 이윤 수동 조정액 — 설계자 명시 입력일 때만. 프로그램이 스스로 채우지 않는다.
|
||||||
|
profit_adjustment_krw: Decimal = _ZERO
|
||||||
|
#: 조정액 줄 산식 칸 — 비면 「설계자 명시 입력」. 절사 자동보정이 제 이름을 적는 자리.
|
||||||
|
profit_adjustment_label: str = ""
|
||||||
|
|
||||||
|
#: 폐기물처리비 — 요율이 아니라 **실비**(수량 × 처리단가).
|
||||||
|
#: ⭐ 2026-09-14 판정 — **비목은 경비**(예정가격작성기준 제19조③18호)라 순공사원가에 들고
|
||||||
|
#: **일반관리비·이윤 밑수에 든다.** 빠지면 총액이 조용히 작아진다.
|
||||||
|
#: ⚠ 법정경비 밑수(직접공사비·노무비)에는 안 넣는다 — 그 밑수는 요율 데이터가 정한 합이다.
|
||||||
|
waste_disposal_krw: Decimal = _ZERO
|
||||||
|
#: 분리발주 — 켜면 공사와 따로 발주하는 용역이라 **총원가 밖**, 총공사비에만 더한다
|
||||||
|
#: (거창 원가계산서 `총공사비 = 도급액 + 관급자재대 + 폐기물처리비` 모양). 기본 꺼짐.
|
||||||
|
waste_separate_order: bool = False
|
||||||
|
#: 폐기물처리비 자리 — `expense`(기본 · 법 문언: 경비 · 일반관리비·이윤 밑수 안) ·
|
||||||
|
#: `after_profit`(실무 관행: 이윤 뒤 · 총원가 안 · 부가세 안 · 승률 밖 — 울진소광·실정보고).
|
||||||
|
#: 분리발주(`waste_separate_order`)가 켜지면 그쪽이 먼저(총원가 밖).
|
||||||
|
waste_placement: str = "expense"
|
||||||
|
|
||||||
|
#: ── 기준 입력 10·11·12 (규칙은 `Engine_Cost_Options`) — 기본값은 종전 계산과 같음 ──
|
||||||
|
#: 원가계산 형식 — 일반관리비 밑수가 형식마다 다름(지금은 `general` 만).
|
||||||
|
form: str = "general"
|
||||||
|
#: 10. 일반관리비 — 요율 표 이름((주)공사 / 전문공사).
|
||||||
|
overhead_class: str = "civil_landscape_industrial"
|
||||||
|
#: 일반 형식 일반관리비 밑수에 더하는 관리품목 자재대.
|
||||||
|
overhead_managed_material_krw: Decimal = _ZERO
|
||||||
|
#: 12. 부가세 방식(`VAT_MODES`) · 산림조합-면세품이면 면세품 금액.
|
||||||
|
vat_mode: str = "supply"
|
||||||
|
tax_exempt_material_krw: Decimal = _ZERO
|
||||||
|
#: 11. 절사 — `grand_total`(총공사비에서 조정) · `supply`(공급가액) · `none`·빈 값은 안 자름.
|
||||||
|
#: ⭐ 2026-09-14 **기본이 켜짐** — 근거 산림청고시 제2025-82호 「금액의 단위표준」
|
||||||
|
#: 「설계서의 총액 · 원 · 1,000 · 미만버림」.
|
||||||
|
#: 실무 6건이 여섯 다 `000` 으로 끝나는 것이 증거.
|
||||||
|
#: ⚠ 2026-09-08 「조용히 깎지 않음」 합의의 정신은 살린다 — 깎은 몫과
|
||||||
|
#: **까닭(고시 번호)** 을 이윤 조정액 줄과 결과 메모에 적고,
|
||||||
|
#: 설계자가 `none` 으로 **끌 수 있게** 둔다.
|
||||||
|
cut_basis: str = "grand_total"
|
||||||
|
cut_unit_krw: int = 1000
|
||||||
|
#: 4. 고용보험 등급 — `auto`(추정금액 구간) · `none`(없음) · `1`~`7`(등급 직접).
|
||||||
|
employment_insurance_grade: str = "auto"
|
||||||
|
#: 5. 퇴직공제부금비 — `auto`(추정금액 1억 이상) · `apply`(적용) · `none`(미적용).
|
||||||
|
#: ⚠ STmate 는 토목·준설·건축·기타로 가르나 현행 제비율(2026-04-13)은 공종 구분 없이 2.3% —
|
||||||
|
#: 율이 안 갈리는 구분은 칸으로 안 세움.
|
||||||
|
retirement_mutual_aid_mode: str = "auto"
|
||||||
|
#: 13. 사급비 위치 — `material`(재료비에 포함) 뿐. 사급은 이미 내역 재료비에 들어 오므로
|
||||||
|
#: 계산에 안 씀(실무 거창·영월 · STmate 기본). 다른 자리는 사급 금액 칸이 설 때 엶.
|
||||||
|
private_material_position: str = "material"
|
||||||
|
#: 14. 낙찰방식 — `not_comprehensive`(종합심사 외) · `comprehensive`(종합심사 대상) · `turnkey`.
|
||||||
|
#: 하도급대금 지급보증 요율 줄만 고름.
|
||||||
|
bid_method: str = "not_comprehensive"
|
||||||
|
#: 하도급대금 지급보증수수료 — `off`(기본) · `on`. 실무 여섯 건에 줄 없음 · 건설산업기본법
|
||||||
|
#: 제34조 대상이면 켬(「대상 아님」인지 「안 적음」인지는 실무로 못 가름).
|
||||||
|
subcontract_guarantee: str = "off"
|
||||||
|
#: 15. 이행보증 — `general`(일반계약: 추정가격 300억 이상만) ·
|
||||||
|
#: `lowest_price_tech`(최저가·기술제안: 규모 무관).
|
||||||
|
performance_guarantee_mode: str = "general"
|
||||||
|
#: 16. 적용기준 — `national`·`local`·`moi`. 계산에 안 씀: 현행 조달청 표 한 벌뿐 · 국가/지방
|
||||||
|
#: 구간 차이(예정가격작성기준 제20조 표)는 원문 이미지라 미확인 · 행자부 표 없음. 칸만 받음.
|
||||||
|
contract_law_basis: str = ""
|
||||||
|
#: 프로젝트 요율 덮어쓰기(`RateOverride` 한 줄씩, 사유 포함) — 발주처별 별도요율.
|
||||||
|
rate_overrides: tuple[dict[str, Any], ...] = ()
|
||||||
|
|
||||||
|
#: 환경보전비 공종 (`rate_environment.all_work_types` 의 값).
|
||||||
|
#: TODO(미결 PLAN 9-6): 임도가 「도로 0.9 %」인지 「기타 토목 0.8 %」인지 미확정.
|
||||||
|
#: 잠정 = 도로(0.9 %). 요율 데이터가 `pending` 을 달고 있어 결과 줄에 경고가 붙는다.
|
||||||
|
environment_work_type: str = "civil_road"
|
||||||
|
#: 건설기계대여대금 지급보증 공종.
|
||||||
|
equipment_guarantee_work_type: str = "civil_general"
|
||||||
|
#: 하도급대금 지급보증 — 30억 이상 구간이 공종으로 갈린다(토목·산업설비 / 건축).
|
||||||
|
subcontract_guarantee_variant: str = "integrated_civil_or_industrial"
|
||||||
|
|
||||||
|
#: 켤 비목. 기본은 「그 해 요율 데이터에 있는 것 전부」.
|
||||||
|
enabled_items: tuple[str, ...] | str = DEFAULT_ITEMS
|
||||||
|
|
||||||
|
#: 요율 데이터 파일명. **연도를 갈아끼우는 자리.**
|
||||||
|
rate_file_name: str = "rates_2026.json"
|
||||||
|
#: 매니페스트 밖 요율 파일(옛 연도 재현 검산 전용). 주면 이쪽이 우선.
|
||||||
|
rate_file_path: str | None = None
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"""B09 원가계산 — **유로폼 사용수량** 12-38-2 (2026-09-14 브레인 301 판정 ①~⑥ · ③′).
|
||||||
|
|
||||||
|
정본 = 산림 12-38-2(10㎡당 패널 0.89매 · 내부 패널 0.03매 · 부자재 주자재비 간단 24 · 보통 52 · 복잡 79% ·
|
||||||
|
소모자재 5%) · 건설 공통 6-3-3 은 참고(고시 총칙 「타 부문과 유사한 공종은 본 품셈 우선」).
|
||||||
|
원문 「자재비는 거래형태 등을 고려하여 **임대료 또는 손료**로 산정」 — 둘을 나란히 주므로 설계자가 고름(제안값 없음).
|
||||||
|
|
||||||
|
고르는 자리 「자재 단가」 탭(화약류와 같은 통로) — 넣은 쪽으로 섬
|
||||||
|
손료 패널·내부 패널 단가 × 표 수량 곧장(실무 넷 — 봉화 「31,500 × 0.89 / 10」) ·
|
||||||
|
부자재·소모자재 = (패널 + 내부 패널) × %(봉화 「2,866.5 × 52%」)
|
||||||
|
임대료 「유로폼 임대료 ㎡당」 한 줄(설계자가 임대기간 반영해 셈) · % 는 원문이 안 적어 안 걺
|
||||||
|
둘 다 · 반쯤 · 아무것도 — 안 섬 + 사유(부모 12-38 까지 그대로 올라감)
|
||||||
|
|
||||||
|
⚠ 12-38-1 잔존율(12회 25%)은 곱하지 않음 — 표 수량이 이미 그 몫으로 보임. 곱하면 두 번 나눔(패널 몫 약 1/16).
|
||||||
|
⚠ 가드(이중계상 ③)에서 12-38-02 를 뺀 까닭은 `B09_Estimation_Guards.SURCHARGE_INCLUDED_ITEMS` 곁에.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
CODE = "FP-12-38-02"
|
||||||
|
PANEL = "AR-M-10aba455"
|
||||||
|
INNER = "AR-M-5b89b294"
|
||||||
|
RENT = "AR-M-d00a6b1a"
|
||||||
|
QUANTITY_TABLE = "F0393"
|
||||||
|
RATE_TABLE = "F0394"
|
||||||
|
|
||||||
|
CHOICE_MISSING = (
|
||||||
|
"자재비 — 임대료 또는 손료 설계자 선택(12-38-2 「거래형태 등을 고려하여 임대료 또는 손료로 산정」)"
|
||||||
|
" · 「자재 단가」 탭에 패널·내부 패널 단가(손료) 또는 유로폼 임대료 ㎡당(임대료) 중 하나를 넣으면 섬"
|
||||||
|
)
|
||||||
|
CHOICE_BOTH = "자재비 — 손료(패널 단가)와 임대료가 둘 다 들어옴 · 하나만 넣을 것"
|
||||||
|
LOSS_HALF = "자재비 손료 — {name} 단가 없음 · 패널·내부 패널 둘 다 넣어야 섬"
|
||||||
|
REUSE_NOTE = (
|
||||||
|
"12-38-2 표 수량 곧장(실무 넷 실증) · 12-38-1 잔존율은 안 곱함 — 표 수량이 이미 12회·잔존율 25% 몫으로"
|
||||||
|
" 보임(우리 역산: 10㎡ ÷ 0.72㎡ = 13.9매 × 0.75 ÷ 12 = 0.87 ≈ 0.89 · 원문이 적지는 않음) · 차 2.5% 는"
|
||||||
|
" [주]① 「할증 및 손율 포함」으로 봄(추정) · 「25회 10%」 는 표에 수량이 없어 안 세움"
|
||||||
|
)
|
||||||
|
RENT_NOTE = "설계자 임대료(12-38-2 「임대료는 시중 물가지 등을 참고하여 결정」) · 부자재·소모자재 % 는 원문이 임대료에 안 적어 안 걺"
|
||||||
|
|
||||||
|
_RE_PERCENT = re.compile(r"(\d+(?:\.\d+)?)\s*%")
|
||||||
|
|
||||||
|
|
||||||
|
def _tight(text: Any) -> str:
|
||||||
|
return re.sub(r"\s", "", str(text or ""))
|
||||||
|
|
||||||
|
|
||||||
|
def _table(node: dict[str, Any], table_id: str) -> dict[str, Any]:
|
||||||
|
return next((t for t in node.get("tables") or [] if t.get("pum_table_id") == table_id), {})
|
||||||
|
|
||||||
|
|
||||||
|
def rates(node: dict[str, Any]) -> dict[str, Decimal]:
|
||||||
|
"""부자재 요율 표(F0394) — 머리 갈래(원문 표기 「간 단」…) → %. 마스터 갈래 키도 이것을 씀."""
|
||||||
|
table = _table(node, RATE_TABLE)
|
||||||
|
heads = table.get("condition_note") or []
|
||||||
|
for row in table.get("raw_row") or []:
|
||||||
|
found = {str(h): _RE_PERCENT.search(str(c)) for h, c in zip(heads[1:], row[1:])}
|
||||||
|
return {h: Decimal(m.group(1)) for h, m in found.items() if m}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _quantities(node: dict[str, Any]) -> tuple[Decimal | None, Decimal | None, Decimal | None]:
|
||||||
|
"""(패널 매/㎡, 내부 패널 매/㎡, 소모자재 %) — 표 F0393 10㎡당을 1㎡당으로."""
|
||||||
|
from B09_Estimation.B09_Estimation_ResourceAxis import parse_amount
|
||||||
|
|
||||||
|
table = _table(node, QUANTITY_TABLE)
|
||||||
|
per = Decimal(str(table.get("basis_quantity") or 0))
|
||||||
|
panel = inner = consumable = None
|
||||||
|
for row in table.get("raw_row") or []:
|
||||||
|
name, value = _tight(row[0]), str(row[-1])
|
||||||
|
if name == "패널" and parse_amount(value) and per:
|
||||||
|
panel = parse_amount(value) / per
|
||||||
|
elif name == "내부패널" and parse_amount(value) and per:
|
||||||
|
inner = parse_amount(value) / per
|
||||||
|
elif name.startswith("소모자재") and _RE_PERCENT.search(value):
|
||||||
|
consumable = Decimal(_RE_PERCENT.search(value).group(1))
|
||||||
|
return panel, inner, consumable
|
||||||
|
|
||||||
|
|
||||||
|
def attach_euroform(build: Any, nodes_by_code: dict[str, dict[str, Any]]) -> None:
|
||||||
|
"""「자재 단가」 칸 셋을 세우고, 넣은 쪽으로 12-38-02 를 세움 — 부모 합산(`attach_parent_steps`) 앞."""
|
||||||
|
from B09_Estimation.B09_Estimation_PriceBook import PriceDetail, PriceKind, PriceTitle
|
||||||
|
|
||||||
|
node = nodes_by_code.get(CODE) or {}
|
||||||
|
for material in (PANEL, INNER, RENT):
|
||||||
|
uses = build.material_uses.setdefault(material, [])
|
||||||
|
if CODE not in uses:
|
||||||
|
uses.append(CODE)
|
||||||
|
book = build.book
|
||||||
|
loss = [m for m in (PANEL, INNER) if m in book.titles]
|
||||||
|
panel, inner, consumable = _quantities(node)
|
||||||
|
table_rates = rates(node)
|
||||||
|
reason = ""
|
||||||
|
if loss and RENT in book.titles:
|
||||||
|
reason = CHOICE_BOTH
|
||||||
|
elif RENT in book.titles:
|
||||||
|
title_code = f"B-{CODE}"
|
||||||
|
book.add_title(
|
||||||
|
PriceTitle(
|
||||||
|
code=title_code,
|
||||||
|
kind=PriceKind.UNIT_PRICE,
|
||||||
|
name="유로폼 사용수량",
|
||||||
|
spec="임대료",
|
||||||
|
unit="㎡",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
book.add_detail(PriceDetail(title_code, RENT, Decimal(1), note=RENT_NOTE))
|
||||||
|
elif len(loss) == 2 and panel and inner and consumable is not None and table_rates:
|
||||||
|
for head, rate in table_rates.items():
|
||||||
|
variant = _tight(head)
|
||||||
|
title_code = f"B-{CODE}#{variant}"
|
||||||
|
book.add_title(
|
||||||
|
PriceTitle(
|
||||||
|
code=title_code,
|
||||||
|
kind=PriceKind.UNIT_PRICE,
|
||||||
|
name=f"유로폼 사용수량 ({variant})",
|
||||||
|
spec=variant,
|
||||||
|
unit="㎡",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
book.add_detail(PriceDetail(title_code, PANEL, panel, note=REUSE_NOTE))
|
||||||
|
book.add_detail(
|
||||||
|
PriceDetail(title_code, INNER, inner, note="12-38-2 내부 패널 표 수량 곧장")
|
||||||
|
)
|
||||||
|
for label, percent in (
|
||||||
|
("부자재(웨지핀·플랫타이·강관파이프·훅)", rate),
|
||||||
|
("소모자재(박리제 등)", consumable),
|
||||||
|
):
|
||||||
|
book.add_detail(
|
||||||
|
PriceDetail(
|
||||||
|
title_code,
|
||||||
|
title_code,
|
||||||
|
Decimal(0),
|
||||||
|
note=f"{label} — 주자재비(패널 + 내부 패널)의 {percent}% (12-38-2)",
|
||||||
|
percent_of_material=percent,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
build.variants.setdefault(CODE, []).append(variant)
|
||||||
|
elif loss:
|
||||||
|
missing = "내부 패널" if PANEL in loss else "패널"
|
||||||
|
reason = LOSS_HALF.format(name=missing)
|
||||||
|
else:
|
||||||
|
reason = CHOICE_MISSING
|
||||||
|
if reason:
|
||||||
|
build.component_gaps[CODE] = reason
|
||||||
|
build.unattached[CODE] = [reason]
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""B09 원가계산 — **발파 화약류 자재**(9-5-1 · 2026-09-14 브레인 300 판정).
|
||||||
|
|
||||||
|
표 F0243 「폭약 kg 0.35 · 뇌관 개 1.0 · 비트 개 0.008」 은 **규격을 안 적음** — 자원 목록(`AR-M`)
|
||||||
|
항목은 있으나 조인 규칙(규격이 같아야 · 후보 하나여도 자동 안 고름)에 걸려 못 붙었음.
|
||||||
|
⇒ 치즐과 같은 모양: 「자재 단가」 탭에 칸이 서고 **설계자가 규격·단가를 넣으면** 표 수량으로 붙음.
|
||||||
|
안 넣으면 「규격 미정」 사유로 못 붙은 줄에 남음(임의로 규격을 고르지 않음).
|
||||||
|
⚠ 잡재료비 「주재료의 5%」(폭약 줄 비고)는 아직 안 걺 — 사유에 적음.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
#: 공종 → (표 이름, 자원 목록 코드). 표 이름은 공백을 지운 글.
|
||||||
|
EXPLOSIVES: dict[str, tuple[tuple[str, str], ...]] = {
|
||||||
|
"FP-09-05-01": (
|
||||||
|
("폭약", "AR-M-6fc2930f"),
|
||||||
|
("뇌관", "AR-M-0965627d"),
|
||||||
|
("비트", "AR-M-a1a18ec4"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
EXPLOSIVE_MISSING = (
|
||||||
|
"{name} — 규격 미정(원문 표가 규격을 안 적음) · 「자재 단가」 탭에서 규격·단가를 넣으면 붙음"
|
||||||
|
)
|
||||||
|
MISC_NOTE = "ⓘ 폭약 줄 비고 「잡재료비: 주재료의 5%」 는 아직 안 걺"
|
||||||
|
#: 착암기 — 건설품셈 8-3-6 (5205) 공기압축기 손료표 [주]① 「부수물(호스포함)은 별도 계상한다」 ·
|
||||||
|
#: 부수물 관계표에 「래그 해머 2.7㎥/min」 · 래그해머 손료표는 고시 없음(2026-09-14 안티그래비티 ·
|
||||||
|
#: 원문 L2646~2683). 압축기 손료에 든다고 **정하지 않음** — 원문이 「별도」.
|
||||||
|
LEG_HAMMER = "착암기2.7㎥/min"
|
||||||
|
LEG_HAMMER_NO_LOSS = (
|
||||||
|
"착암기 2.7㎥/min — 공기압축기의 부수물 「래그 해머」(건설품셈 8-3-6 (5205) [주]① 「부수물은"
|
||||||
|
" 별도 계상」)이라 압축기 손료에 안 듦 · 래그해머 손료표는 원문에 고시 없음 → 손료를 못 셈"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _table_amount(node: dict[str, Any], name: str) -> Decimal | None:
|
||||||
|
"""표에서 그 이름 줄의 첫 수 — 없으면 `None`."""
|
||||||
|
from B09_Estimation.B09_Estimation_ResourceAxis import parse_amount
|
||||||
|
|
||||||
|
for table in node.get("tables") or []:
|
||||||
|
for row in table.get("raw_row") or []:
|
||||||
|
cells = [str(cell) for cell in row]
|
||||||
|
names = ["".join(cell.split()) for cell in cells]
|
||||||
|
if name not in names:
|
||||||
|
continue
|
||||||
|
index = names.index(name)
|
||||||
|
value = next((parse_amount(c) for c in cells[index + 1 :] if parse_amount(c)), None)
|
||||||
|
if value is not None:
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def attach_explosives(build: Any, nodes_by_code: dict[str, dict[str, Any]]) -> None:
|
||||||
|
"""칸을 세우고(`material_uses`) · 단가가 든 것은 붙이고 · 안 든 것은 사유로 갈아 끼움."""
|
||||||
|
from B09_Estimation.B09_Estimation_PriceBook import PriceDetail
|
||||||
|
|
||||||
|
for code, items in EXPLOSIVES.items():
|
||||||
|
node = nodes_by_code.get(code) or {}
|
||||||
|
labels = list(build.unattached.get(code) or [])
|
||||||
|
for name, material in items:
|
||||||
|
build.material_uses.setdefault(material, [])
|
||||||
|
if code not in build.material_uses[material]:
|
||||||
|
build.material_uses[material].append(code)
|
||||||
|
amount = _table_amount(node, name)
|
||||||
|
labels = [label for label in labels if "".join(label.split()) != name]
|
||||||
|
if amount is None:
|
||||||
|
continue
|
||||||
|
titles = [
|
||||||
|
t for t in build.book.titles if t == f"B-{code}" or t.startswith(f"B-{code}#")
|
||||||
|
]
|
||||||
|
if material in build.book.titles and titles:
|
||||||
|
for title in titles:
|
||||||
|
build.book.add_detail(
|
||||||
|
PriceDetail(title, material, amount, note=f"{name} — 설계자 규격·단가")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
labels.append(EXPLOSIVE_MISSING.format(name=name))
|
||||||
|
if MISC_NOTE not in labels:
|
||||||
|
labels.append(MISC_NOTE)
|
||||||
|
labels = [
|
||||||
|
LEG_HAMMER_NO_LOSS if "".join(label.split()) == LEG_HAMMER else label
|
||||||
|
for label in labels
|
||||||
|
]
|
||||||
|
build.unattached[code] = labels
|
||||||
@@ -231,10 +231,13 @@ def check_handoff_boundaries(violations: list[str] | None) -> None:
|
|||||||
|
|
||||||
|
|
||||||
#: 품셈 [주]가 「재료량에 할증 포함」이라 적은 공종 — 그 재료가 일위대가 재료비로 붙으면
|
#: 품셈 [주]가 「재료량에 할증 포함」이라 적은 공종 — 그 재료가 일위대가 재료비로 붙으면
|
||||||
#: **할증 뒤 값**이 들어가 자재총괄에서 한 번 더 붙는다(㉠). 원문 넷, 코드는 마스터가 붙인 자리.
|
#: **할증 뒤 값**이 들어가 자재총괄에서 한 번 더 붙는다(㉠). 원문 셋, 코드는 마스터가 붙인 자리.
|
||||||
|
#: ⚠ 유로폼 사용수량 12-38-02([주]① 「할증 및 손율이 포함」)는 **뺐음**(2026-09-14 브레인 301 ④) —
|
||||||
|
#: 이 가드의 전제 「자재총괄에서 할증이 한 번 더」가 안 섬: 패널은 B08 자재총괄에 줄이 없음(유로폼 ㎡ 갈 곳
|
||||||
|
#: `unit_price`) · 손료 수량이라 할증 전 값이 원문에 없음 · 실무 넷도 일위대가에 곧장 붙임.
|
||||||
|
#: 유로폼·패널이 자재총괄로 가는 날 되돌릴 것 — `test_b09_euroform` 이 그 자리에서 빨강.
|
||||||
SURCHARGE_INCLUDED_ITEMS: dict[str, str] = {
|
SURCHARGE_INCLUDED_ITEMS: dict[str, str] = {
|
||||||
"FP-12-02": "용적 배합 콘크리트 참고표 「재료량에는 할증률이 포함」(마스터가 12-2 에 붙임)",
|
"FP-12-02": "용적 배합 콘크리트 참고표 「재료량에는 할증률이 포함」(마스터가 12-2 에 붙임)",
|
||||||
"FP-12-38-02": "유로폼 사용수량 [주]① 「재료량에는 재료의 할증 및 손율이 포함」",
|
|
||||||
"FP-13-11-04": "돌망태 사각형 [주]① 「자재비에는 재료의 할증을 포함」",
|
"FP-13-11-04": "돌망태 사각형 [주]① 「자재비에는 재료의 할증을 포함」",
|
||||||
"AX-WK-c0842a0d": "모르타르 배합 참고자료 ※ 「위 재료량은 할증이 포함된 것이다」",
|
"AX-WK-c0842a0d": "모르타르 배합 참고자료 ※ 「위 재료량은 할증이 포함된 것이다」",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,75 @@ KNOWN_GAPS: dict[str, tuple[str, str]] = {
|
|||||||
"ⓘ 성토면 · 기계: 원문이 굴착기 0.6㎥ 형식을 안 적어 무한궤도·타이어 두 갈래로 세움"
|
"ⓘ 성토면 · 기계: 원문이 굴착기 0.6㎥ 형식을 안 적어 무한궤도·타이어 두 갈래로 세움"
|
||||||
"(제안 무한궤도 — 영월 실무 「06M3 B/H」 · 2026-09-14 브레인 ②).",
|
"(제안 무한궤도 — 영월 실무 「06M3 B/H」 · 2026-09-14 브레인 ②).",
|
||||||
),
|
),
|
||||||
|
# 2026-09-14 ㉮ — 사용횟수 갈래로 푼 뒤 남는 원문 몫. 값을 짓지 않고 말만.
|
||||||
|
# 봉상후렉시블 셋의 표 나머지 줄은 판정표 자동 목록(`_unread_rows`)이 맡음 — 줄이 아닌 [주] 만 여기.
|
||||||
|
"FP-12-12": ("원문 [주]", "ⓘ [주] 「성토부 날개벽 설치시 인건비 30% 할증」 은 안 걺(선택)."),
|
||||||
|
# ㉡-3(2026-09-15) — 시간 줄 표 둘 · 막힌 셋은 옛 값이 왜 틀렸는지(풀리는 날 두 번 세지 않게 · 브레인 ③).
|
||||||
|
"FP-14-01": (
|
||||||
|
"원문 표",
|
||||||
|
"ⓘ 원문 「0.8㎥ 굴착기(우드그랩 부착)」 가 굴착기 형식을 안 적어 무한궤도·타이어 두 갈래 · 우드그랩 ↔ 카탈로그"
|
||||||
|
" 「부착용 집게 7206-0070(0.6∼0.8)」 — 이름이 다름 · 인력 「h」 는 시간이라 총칙 일일 작업시간 8시간으로 ÷8.",
|
||||||
|
),
|
||||||
|
"FP-14-02": (
|
||||||
|
"원문 표",
|
||||||
|
"ⓘ 원문 「0.8㎥ 굴착기」 가 형식을 안 적어 무한궤도·타이어 두 갈래 · 인력 「h」 는 시간이라 총칙 일일 작업시간"
|
||||||
|
" 8시간으로 ÷8 · 옛 11,356원은 0.66h 를 인으로 셈(8배) + 굴착기 줄이 빠진 값.",
|
||||||
|
),
|
||||||
|
"FP-12-26": (
|
||||||
|
"막힘 까닭",
|
||||||
|
"ⓘ 양수기(150㎜)·디젤엔진(15HP 11.19kW) 가동시간이 원문 표에 없음 · 카탈로그 양수기는 1.49㎾ 뿐 · 엔진(디젤)"
|
||||||
|
" 손료계수 없음 → 금액 안 세움 · 옛 172,068원은 보통인부 1인 값뿐(기계·목도 4인 빠짐).",
|
||||||
|
),
|
||||||
|
"FP-10-07-04": (
|
||||||
|
"막힘 까닭",
|
||||||
|
"ⓘ 운전 단가표(1일) 보통인부 2 는 적재·짐부리기 보통인부 2인(「운전조작」 포함)과 같은 사람 — 두 번 세지 말 것 ·"
|
||||||
|
" 연료 「ps × 0.253ℓ × 6h」 의 ps 입력 없음 · 본기계·레일은 임대료·견적 → 금액 안 세움 · 옛 688,272원은"
|
||||||
|
" 보통인부를 두 번 센 4인 값.",
|
||||||
|
),
|
||||||
|
"FP-10-08-03": (
|
||||||
|
"막힘 까닭",
|
||||||
|
"ⓘ 연료 = 윈치 기관 출력([주]② 36~73kW) × 운전시간([주]① 4.3~6.7h) × ℓ/kWh — ℓ/kWh 가 원문에 없음 · 보통인부는"
|
||||||
|
" 10-8-1 짐내리기 품 · 운반기구 손료 별도 → 금액 안 세움 · 옛 226,122원은 특별인부 1인 값뿐.",
|
||||||
|
),
|
||||||
|
"FP-08-06-01": (
|
||||||
|
"원문 [주]",
|
||||||
|
"ⓘ 2-1-8 [주]② 「소방관서의 급수 지원을 받은 경우에는 휘발유(양수기)는 미반영」 — 해당하면 양수기 몫을"
|
||||||
|
" 뺄 것 · 8-6-1 [주]③ 실제 사용하지 않는 품은 제외.",
|
||||||
|
),
|
||||||
|
"FP-08-11": (
|
||||||
|
"원문 [주]",
|
||||||
|
"ⓘ [주]③ 장비 운반비는 별도 계상(기계 수송비 칸) · [주]④ 우드그랩은 원목 규격에 따라 별도 · [주]② 추가"
|
||||||
|
" 인력(파쇄 후 마대담기 등)은 조사해 반영 · [주]⑥ 이 규격 외 파쇄기는 견적 — 이 일위대가엔 안 넣음.",
|
||||||
|
),
|
||||||
|
# 원문 대 실무 어긋남 기록(2026-09-15 브레인 규칙 — 원문이 또렷하면 원문 · 어긋남은 늘 기록).
|
||||||
|
"FP-09-15-02": (
|
||||||
|
"실무 어긋남",
|
||||||
|
"ⓘ 실무 영월 「표토제거 답외구간」 호표 Q=576.95 는 E 자리에 e(0.96)를 넣은 값으로 역산됨"
|
||||||
|
"(60×3.07×0.77×0.96÷(1.18×0.2)) — 원문 9-15-2 가 E=0.4 로 또렷해 원문으로 셈 · 다른 실무(봉화·대흥·"
|
||||||
|
"소광·거창)엔 이 호표 없음.",
|
||||||
|
),
|
||||||
|
"FP-12-34-01": (
|
||||||
|
"원문 머리",
|
||||||
|
"ⓘ 원문 머리 「(단위: 개소당)」 ↔ 인력 콘크리트공 0.17·보통인부 0.29 가 12-16 맨홀 ㎥당(0.17인/㎥"
|
||||||
|
" · 0.29인/㎥)과 같고 기계도 Q=5.4㎥/hr ⇒ ㎥당으로 읽음(고른 쪽 ㎥당 · 버린 쪽 개소당) ·"
|
||||||
|
" 봉상후렉시블(45mm) 대 2 는 엔진식 진동기의 봉이라 두 번 안 셈(2026-09-14 브레인).",
|
||||||
|
),
|
||||||
|
"FP-12-04": (
|
||||||
|
"원문 [주]",
|
||||||
|
"ⓘ 「사용고재 평가기준 23%(합판과 각재의 설계단가 기준)」 은 원문이 셈을 안 줘 값으로 안 씀"
|
||||||
|
" — [주]① 2회 이상 사용 고재량은 재료비 비율에 기포함. "
|
||||||
|
"ⓘ 재료(합판·못 카탈로그 없음 · 각재·철선·박리제 규격 미정)는 못 붙은 줄 — 지금 값은"
|
||||||
|
" **인력 품만** · [주]③ 동바리 별도 · [주]⑥ 소형구조물 인력품 30% 할증(선택)은 안 걺.",
|
||||||
|
),
|
||||||
|
"FP-05-24": (
|
||||||
|
"실무 역산",
|
||||||
|
"ⓘ 원문이 애매해 실무(거창 「초류종자살포(씨드스프레이)」)를 따른 자리 둘 — 뒤집을 수 있게 어긋남을"
|
||||||
|
" 나란히: ① 「트럭 4.5ton」 → 덤프트럭 4.5(원문은 트럭 · 카탈로그에 트럭 없음 · 거창이 덤프트럭 4.5Ton)"
|
||||||
|
" ② 「종자살포기 2,500-3,000ℓ」 → 취부기 11.94㎾(원문 규격은 ℓ 탱크 용량 · 카탈로그는 ㎾ · 건설 4-1-3"
|
||||||
|
"·거창이 취부기) · 취부기는 손료만(8-4 운전경비표에 줄 없음 · 거창도 손료만) · ⚠ 거창은 디젤엔진 11.19㎾"
|
||||||
|
" 를 따로 세움 — 산림 5-24 · 건설 4-1-3 표 둘 다에 없어 안 넣음 · 물탱크 조종원은 화물차운전사(8-1-2 5호"
|
||||||
|
" 살수차) · [주]⑦ 물주기(인력) 보통인부 0.0005인은 필요시라 안 걺(2026-09-15 브레인).",
|
||||||
|
),
|
||||||
"FP-12-25": (
|
"FP-12-25": (
|
||||||
"운반거리 미정",
|
"운반거리 미정",
|
||||||
"⚠ 이 값에는 **운반 몫이 빠져 있습니다** — 품셈 12-25 는 「운반 | 덤프트럭(15ton)」 줄을 "
|
"⚠ 이 값에는 **운반 몫이 빠져 있습니다** — 품셈 12-25 는 「운반 | 덤프트럭(15ton)」 줄을 "
|
||||||
@@ -76,13 +145,7 @@ CONDITIONAL_INCLUDED: dict[str, str] = {
|
|||||||
#: (표 읽기 사유 「박리제 줄을 못 풀었습니다」는 사람을 엉뚱한 데로 보낸다).
|
#: (표 읽기 사유 「박리제 줄을 못 풀었습니다」는 사람을 엉뚱한 데로 보낸다).
|
||||||
#: ⚠ 이 구현이 서면 그 줄을 지운다.
|
#: ⚠ 이 구현이 서면 그 줄을 지운다.
|
||||||
BLOCKED_BY_DESIGN: dict[str, str] = {
|
BLOCKED_BY_DESIGN: dict[str, str] = {
|
||||||
# 2026-09-13 브레인 판정 — B09 일위대가 일감(B08 Formwork 머리말 「횟수별 재료 환산은 여기서
|
# (비어 있음) 12-4 합판거푸집은 2026-09-14 사용횟수 갈래로 풀려 걷음(`_JudgedTable`).
|
||||||
# 하지 않는다」). 표를 그대로 풀면 1회 사용 값(재료·노무 100 %)으로 조용히 비싸진다.
|
|
||||||
"FP-12-04": (
|
|
||||||
"거푸집 **사용횟수별 비율**(12-4 「1회 100 % · 4회 40 %…」)을 일위대가에 거는 셈이"
|
|
||||||
" 아직 없어"
|
|
||||||
" 풀지 않았습니다 — 풀면 1회 사용 값으로 비싸게 섭니다"
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -108,6 +171,41 @@ def known_gap_note(code: str | None) -> str:
|
|||||||
return " / ".join(parts)
|
return " / ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
#: 표 형태 이름 — 판정 까닭 한 줄에 붙임.
|
||||||
|
_FORM_LABELS = {
|
||||||
|
"reference": "참조표",
|
||||||
|
"coefficient": "계수표",
|
||||||
|
"productivity": "생산량형",
|
||||||
|
"requirement": "소요량형",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def form_judgment_note(code: str | None) -> str:
|
||||||
|
"""사람이 가른 표 형태 까닭 — 원문 근거 없는 **SW 규칙**이라 화면에 드러냄(10-A ⑭ · 09-14).
|
||||||
|
|
||||||
|
정본은 B08 마스터 빌더의 판정표 한 벌(`_Forms.FORM_JUDGMENTS`) — 여기서 다시 적지 않고 읽음.
|
||||||
|
"""
|
||||||
|
plain = str(code or "").split("#")[0]
|
||||||
|
if not plain:
|
||||||
|
return ""
|
||||||
|
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Forms import FORM_JUDGMENTS
|
||||||
|
from B09_Estimation.B09_Estimation_ResourceAxis import load_work_item_master
|
||||||
|
|
||||||
|
node = next(
|
||||||
|
(n for n in load_work_item_master()["work_items"] if n.get("work_item_code") == plain),
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
parts = []
|
||||||
|
for table in node.get("tables") or []:
|
||||||
|
found = FORM_JUDGMENTS.get(str(table.get("pum_table_id")))
|
||||||
|
if found:
|
||||||
|
_section, form, why = found
|
||||||
|
parts.append(f"{table['pum_table_id']} {_FORM_LABELS.get(form, form)}: {why}")
|
||||||
|
if not parts:
|
||||||
|
return ""
|
||||||
|
return "ⓘ 표 형태는 사람이 가름(원문 근거 없음 · SW 규칙) — " + " / ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
#: 관부설 품셈 표가 다루는 관경 — 그 밖은 **표에 없는 것**이지 값이 틀린 것이 아니다.
|
#: 관부설 품셈 표가 다루는 관경 — 그 밖은 **표에 없는 것**이지 값이 틀린 것이 아니다.
|
||||||
PIPE_TABLE_DIAMETERS_MM = (800, 1000, 1200)
|
PIPE_TABLE_DIAMETERS_MM = (800, 1000, 1200)
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,10 @@
|
|||||||
(1-4-1 「어린나무가꾸기에 한하여」 · 1-4-2 「줄베기」 · 1-4-9 「숲가꾸기 및 병해충방제
|
(1-4-1 「어린나무가꾸기에 한하여」 · 1-4-2 「줄베기」 · 1-4-9 「숲가꾸기 및 병해충방제
|
||||||
작업로」…). **임도 토공에 붙이라는 지시가 원문에 없다.** 그래서 켜는 것은 사용자 몫이고,
|
작업로」…). **임도 토공에 붙이라는 지시가 원문에 없다.** 그래서 켜는 것은 사용자 몫이고,
|
||||||
각 계열의 **[주] 원문을 화면에 그대로** 띄워 어디에 쓰라는 표인지 보이게 한다.
|
각 계열의 **[주] 원문을 화면에 그대로** 띄워 어디에 쓰라는 표인지 보이게 한다.
|
||||||
㉡ **여럿을 고를 때 합산인가 곱인가** — 원문에 없다. 지금은 **합산**으로 두고 그 사실을
|
㉡ **여럿을 고를 때 합산인가 곱인가** — 산림품셈 1-4 에는 없고 **건설 공통 1-4-2 「할증의
|
||||||
화면 근거에 적는다(실무 서식이 대개 합산이나 원문 근거는 아니다).
|
중복가산요령」** 이 정함(교차 참조 · 2026-09-14 브레인 672): 「W = 기본품 × (1 + a1 + … + an)
|
||||||
|
· 단, 동일성격의 품할증요소의 이중적용은 불가」 → **합산**. 「동일성격」이 어느 계열끼리인지는
|
||||||
|
원문이 안 정해 막지 않고 단서를 화면에 보임(설계자가 가림).
|
||||||
|
|
||||||
**기본은 「안 고름」** — 한 계열도 안 고르면 금액이 한 원도 안 움직인다.
|
**기본은 「안 고름」** — 한 계열도 안 고르면 금액이 한 원도 안 움직인다.
|
||||||
|
|
||||||
@@ -46,13 +48,14 @@ _NOTE_LOOKAHEAD = 12
|
|||||||
_RE_PERCENT = re.compile(r"^-?\d+(?:\.\d+)?%$")
|
_RE_PERCENT = re.compile(r"^-?\d+(?:\.\d+)?%$")
|
||||||
_RE_SECTION = re.compile(r"^(1-4-\d+)\.\s*(.+)$")
|
_RE_SECTION = re.compile(r"^(1-4-\d+)\.\s*(.+)$")
|
||||||
|
|
||||||
#: ⚠ **여럿을 고를 때 어떻게 셈하나 — 원문이 안 정한 자리다.**
|
#: 여럿을 고를 때 셈법 — 건설 공통 1-4-2 「W = 기본품 × (1 + a1 + … + an)」 합산(교차 참조 · 672).
|
||||||
#: 지금은 「합산」이고 **여기 한 곳만 갈아 끼우면 바뀐다**(코드 깊이 박지 않는다).
|
#: **여기 한 곳**이 정한다(코드 깊이 박지 않는다).
|
||||||
#: `"sum"` = 10% + 5% = 15% · `"product"` = 1.10 × 1.05 − 1 = 15.5%
|
#: `"sum"` = 10% + 5% = 15% · `"product"` = 1.10 × 1.05 − 1 = 15.5%
|
||||||
COMBINE_RULE = "sum"
|
COMBINE_RULE = "sum"
|
||||||
COMBINE_NOTE = (
|
COMBINE_NOTE = (
|
||||||
"⚠ 여럿을 고르면 더합니다 — 원문이 합산인지 곱인지 안 정해 우리가 그렇게 두었습니다"
|
"여럿을 고르면 합산 — 건설 공통 1-4-2 「W = 기본품 × (1 + a1 + a2 + … + an)」"
|
||||||
" (사용자 확정 대기)."
|
"(산림품셈 1-4 에 겹침 규정이 없어 교차 참조) · ⚠ 같은 조 단서 「동일성격의 품할증요소의"
|
||||||
|
" 이중적용은 불가」 — 어느 계열끼리 동일성격인지는 원문이 안 정해 설계자가 가림"
|
||||||
)
|
)
|
||||||
SEAT_NOTE = (
|
SEAT_NOTE = (
|
||||||
"품 할인·할증은 품(인력) 줄에 붙습니다 — 물량에 곱하면 자재·기계까지 부풀어"
|
"품 할인·할증은 품(인력) 줄에 붙습니다 — 물량에 곱하면 자재·기계까지 부풀어"
|
||||||
|
|||||||
@@ -145,6 +145,15 @@ GLUED_MACHINE_FIXES: dict[str, tuple[str, str]] = {
|
|||||||
"7120-0746": ("버킷식준설기", "7.46kW"),
|
"7120-0746": ("버킷식준설기", "7.46kW"),
|
||||||
"7995-0050": ("배관파이프", "ø50-2.6m"),
|
"7995-0050": ("배관파이프", "ø50-2.6m"),
|
||||||
}
|
}
|
||||||
|
#: ⚠ **한 칸에 두 줄이 뭉친 표** (2026-09-14 661 뒤 ① 브레인) — 원천이 (4611) 콘크리트 진동기 표의
|
||||||
|
#: 두 기종(「4611-0075 0350」 · 규격·계수가 한 칸에 둘씩)을 못 갈라 **규격이 비고 손료계수가 없음**.
|
||||||
|
#: 규격·시간당 계는 **건설공사 표준품셈 제8장 (4611)** 원문(L2504) 그대로 — 판정 없음.
|
||||||
|
#: 상각 3,000 + 정비 1,167 + 관리 768 = 4,935 · 3,000 + 1,333 + 768 = 5,101 (표의 「계」와 같음)
|
||||||
|
#: ⚠ 원천이 바로 실으면 이 표는 지운다(위 표와 같은 약속). 취득가는 원천 값 그대로.
|
||||||
|
MERGED_ROW_FIXES: dict[str, tuple[str, Decimal]] = {
|
||||||
|
"4611-0075": ("전기식 플렉시블형 ø45(0.75㎾)", Decimal("0.0004935")),
|
||||||
|
"4611-0350": ("엔진식 플렉시블형 ø45(2.6㎾)", Decimal("0.0005101")),
|
||||||
|
}
|
||||||
#: 이름이 빈 분류 — 분류번호(코드 앞 넷) → 원문 이름. 규격은 원천 값 그대로.
|
#: 이름이 빈 분류 — 분류번호(코드 앞 넷) → 원문 이름. 규격은 원천 값 그대로.
|
||||||
EMPTY_NAME_BY_GROUP: dict[str, str] = {
|
EMPTY_NAME_BY_GROUP: dict[str, str] = {
|
||||||
"0240": "유압식 진동콤팩터(굴착기 부착용)",
|
"0240": "유압식 진동콤팩터(굴착기 부착용)",
|
||||||
@@ -234,6 +243,10 @@ def load_machine_catalog(file_name: str = "mach_base_2026.json") -> MachineCatal
|
|||||||
elif code in GLUED_MACHINE_FIXES:
|
elif code in GLUED_MACHINE_FIXES:
|
||||||
name, spec = GLUED_MACHINE_FIXES[code]
|
name, spec = GLUED_MACHINE_FIXES[code]
|
||||||
row = {**row, "machine_name": name, "specification": spec}
|
row = {**row, "machine_name": name, "specification": spec}
|
||||||
|
elif code in MERGED_ROW_FIXES and "loss_coefficient_per_hour" not in coefficient:
|
||||||
|
spec, merged_coefficient = MERGED_ROW_FIXES[code]
|
||||||
|
row = {**row, "specification": spec}
|
||||||
|
coefficient = {**coefficient, "loss_coefficient_per_hour": merged_coefficient}
|
||||||
elif not str(row.get("machine_name") or "").strip() and code[:4] in EMPTY_NAME_BY_GROUP:
|
elif not str(row.get("machine_name") or "").strip() and code[:4] in EMPTY_NAME_BY_GROUP:
|
||||||
row = {**row, "machine_name": EMPTY_NAME_BY_GROUP[code[:4]]}
|
row = {**row, "machine_name": EMPTY_NAME_BY_GROUP[code[:4]]}
|
||||||
catalog.machines[code] = MachineSpec(
|
catalog.machines[code] = MachineSpec(
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ def machine_expense_sheets(build: Any) -> list[dict[str, Any]]:
|
|||||||
"""**내역에 실제로 선 기종만** 한 장씩. 안 쓰는 613 기종을 다 뿌리지 않는다."""
|
"""**내역에 실제로 선 기종만** 한 장씩. 안 쓰는 613 기종을 다 뿌리지 않는다."""
|
||||||
from B09_Estimation.B09_Estimation_MachineOperating import load_fuel_price
|
from B09_Estimation.B09_Estimation_MachineOperating import load_fuel_price
|
||||||
from B09_Estimation.B09_Estimation_MachineOperating import (
|
from B09_Estimation.B09_Estimation_MachineOperating import (
|
||||||
|
LOSS_ONLY_MACHINES,
|
||||||
load_operating_records,
|
load_operating_records,
|
||||||
load_operator_wages,
|
load_operator_wages,
|
||||||
)
|
)
|
||||||
@@ -88,7 +89,6 @@ def machine_expense_sheets(build: Any) -> list[dict[str, Any]]:
|
|||||||
catalog = load_machine_catalog()
|
catalog = load_machine_catalog()
|
||||||
operating = {row.machine_code: row for row in load_operating_records().records}
|
operating = {row.machine_code: row for row in load_operating_records().records}
|
||||||
wages = load_operator_wages()
|
wages = load_operator_wages()
|
||||||
fuel_price, fuel_meta = load_fuel_price()
|
|
||||||
loss = _loss_records()
|
loss = _loss_records()
|
||||||
|
|
||||||
sheets: list[dict[str, Any]] = []
|
sheets: list[dict[str, Any]] = []
|
||||||
@@ -101,11 +101,33 @@ def machine_expense_sheets(build: Any) -> list[dict[str, Any]]:
|
|||||||
continue
|
continue
|
||||||
record = operating.get(machine_code)
|
record = operating.get(machine_code)
|
||||||
raw = loss.get(machine_code) or {}
|
raw = loss.get(machine_code) or {}
|
||||||
|
if variant == "암석":
|
||||||
|
# 암석 손료보정(8-1-7 1) — 장도 보정한 상각·정비로 보여야 「계」와 맞음(661 뒤처리).
|
||||||
|
from B09_Estimation.B09_Estimation_RockLoss import rock_parts
|
||||||
|
|
||||||
|
parts = rock_parts(machine_code)
|
||||||
|
if parts is not None:
|
||||||
|
keys = ("depreciation", "maintenance", "management", "source")
|
||||||
|
# 화면 JSON 은 수 — Decimal 을 그대로 실으면 응답이 안 섬
|
||||||
|
raw = {
|
||||||
|
**raw,
|
||||||
|
**{f"{k}_coefficient_1e_minus_7": float(v) for k, v in zip(keys, parts)},
|
||||||
|
}
|
||||||
|
loss_per_hour = (
|
||||||
|
Decimal(str(int(raw["source_coefficient_1e_minus_7"]))) * Decimal("1e-7")
|
||||||
|
if variant == "암석" and "source_coefficient_1e_minus_7" in raw
|
||||||
|
else machine.loss_coefficient_per_hour
|
||||||
|
)
|
||||||
money = build.book.resolve(code)
|
money = build.book.resolve(code)
|
||||||
|
|
||||||
gaps: list[str] = []
|
gaps: list[str] = []
|
||||||
attachment = machine_code.startswith(_ATTACHMENT_PREFIXES)
|
loss_only = LOSS_ONLY_MACHINES.get(machine_code, "") # 8-4 에 줄 없음 · 손료만(②′)
|
||||||
|
attachment = machine_code.startswith(_ATTACHMENT_PREFIXES) or bool(loss_only)
|
||||||
fuel_liters = getattr(record, "fuel_liters_per_hour", None)
|
fuel_liters = getattr(record, "fuel_liters_per_hour", None)
|
||||||
|
# 연료 종류대로 그 유가(휘발유 기계가 경유값으로 보이던 자리 · 661 뒤 ②).
|
||||||
|
fuel_price, fuel_meta = (
|
||||||
|
load_fuel_price(kind=record.fuel_kind) if fuel_liters is not None else (None, {})
|
||||||
|
)
|
||||||
misc_percent = getattr(record, "misc_material_percent", None)
|
misc_percent = getattr(record, "misc_material_percent", None)
|
||||||
occupation = getattr(record, "operator_occupation_code", "") or ""
|
occupation = getattr(record, "operator_occupation_code", "") or ""
|
||||||
wage = wages.get(occupation)
|
wage = wages.get(occupation)
|
||||||
@@ -136,8 +158,8 @@ def machine_expense_sheets(build: Any) -> list[dict[str, Any]]:
|
|||||||
"management_coefficient": raw.get("management_coefficient_1e_minus_7"),
|
"management_coefficient": raw.get("management_coefficient_1e_minus_7"),
|
||||||
"loss_coefficient": raw.get("source_coefficient_1e_minus_7"),
|
"loss_coefficient": raw.get("source_coefficient_1e_minus_7"),
|
||||||
"loss_krw_per_hour": _money(
|
"loss_krw_per_hour": _money(
|
||||||
machine.price_thousand_krw * _THOUSAND * machine.loss_coefficient_per_hour
|
machine.price_thousand_krw * _THOUSAND * loss_per_hour
|
||||||
if machine.loss_coefficient_per_hour is not None
|
if loss_per_hour is not None
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
# ② 운전경비
|
# ② 운전경비
|
||||||
@@ -145,7 +167,7 @@ def machine_expense_sheets(build: Any) -> list[dict[str, Any]]:
|
|||||||
"fuel_price_per_liter": _money(fuel_price),
|
"fuel_price_per_liter": _money(fuel_price),
|
||||||
"fuel_scope": fuel_meta.get("region_name") or "전국 공시가",
|
"fuel_scope": fuel_meta.get("region_name") or "전국 공시가",
|
||||||
"misc_material_percent": (
|
"misc_material_percent": (
|
||||||
str(COMBINED_MISC_PERCENT) if variant else _money(misc_percent)
|
str(COMBINED_MISC_PERCENT) if variant == "조합" else _money(misc_percent)
|
||||||
),
|
),
|
||||||
"operator_code": occupation,
|
"operator_code": occupation,
|
||||||
"operator_daily_wage": _money(wage),
|
"operator_daily_wage": _money(wage),
|
||||||
@@ -161,7 +183,7 @@ def machine_expense_sheets(build: Any) -> list[dict[str, Any]]:
|
|||||||
"expense_krw": _money(money.expense),
|
"expense_krw": _money(money.expense),
|
||||||
"total_krw": _money(money.total),
|
"total_krw": _money(money.total),
|
||||||
"attachment": attachment,
|
"attachment": attachment,
|
||||||
"attachment_note": ATTACHMENT_NOTE if attachment else "",
|
"attachment_note": loss_only or (ATTACHMENT_NOTE if attachment else ""),
|
||||||
"gaps": gaps,
|
"gaps": gaps,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -43,11 +43,38 @@ _RE_FUEL_WITH_KIND = re.compile(r"^(휘발유|중유|경유)?(\d+(?:\.\d+)?)$")
|
|||||||
#: 조종원 직종 — 품셈 표는 「인/일」 수만 주고 직종명을 안 준다.
|
#: 조종원 직종 — 품셈 표는 「인/일」 수만 주고 직종명을 안 준다.
|
||||||
#: TODO(미결 PLAN 9-6): 기종별 직종이 품셈 다른 장에 있다. 아래는 `aliases` 기반 **잠정**이며
|
#: TODO(미결 PLAN 9-6): 기종별 직종이 품셈 다른 장에 있다. 아래는 `aliases` 기반 **잠정**이며
|
||||||
#: 결과에 `operator_mapping_is_provisional: true` 로 드러난다.
|
#: 결과에 `operator_mapping_is_provisional: true` 로 드러난다.
|
||||||
_OPERATOR_TRUCK_WORDS = ("덤프트럭", "트럭", "트레일러", "화물")
|
#: 「살수차」 — 8-1-2 5호 화물차운전사 줄이 이름으로 적음(원문 또렷 · 물탱크(살수차) 7204 · 2026-09-15 ③).
|
||||||
|
_OPERATOR_TRUCK_WORDS = ("덤프트럭", "트럭", "트레일러", "화물", "살수차")
|
||||||
_OPERATOR_ALIAS_TRUCK = "labor_op_truck"
|
_OPERATOR_ALIAS_TRUCK = "labor_op_truck"
|
||||||
_OPERATOR_ALIAS_CONSTRUCTION = "labor_op_const"
|
_OPERATOR_ALIAS_CONSTRUCTION = "labor_op_const"
|
||||||
|
|
||||||
|
|
||||||
|
#: ⚠ **셀 병합으로 줄째 버려지는 표에서 원문 값을 되살린다** (2026-09-15 초류종자살포 ③ 브레인).
|
||||||
|
#: 8-4-8 기타기계(원문 L3458)는 36기종이 한 칸에 뭉치고 자동세륜기 「-」 하나가 두 기종 몫이라 잡재료 35 ·
|
||||||
|
#: 조종원 34 개로 짝이 안 맞아 줄째 버려짐. 양끝 맞추기는 우연 일치로 틀린 값이 붙어 못 씀 → 공종이 쓰는
|
||||||
|
#: 물탱크(살수차) 다섯만 손으로(주연료 칸 · 잡재료 30 · 조종원 1 — 앞 기종 수가 칸마다 같아 자리가 또렷).
|
||||||
|
#: 버려진 줄 셋 81기종 중 공종이 쓰는 것은 이 다섯뿐(나머지는 막는 공종 0 이라 미룸).
|
||||||
|
#: 거창 실무 5,500ℓ 호표 「경유 9.3ℓ · 잡품 30% · 화물차운전사 1인」 일치. ⚠ 원천이 바로 실으면 이 표는 지운다.
|
||||||
|
MERGED_CELL_OPERATING_FIXES: dict[str, str] = {
|
||||||
|
"7204-0018": "8.2",
|
||||||
|
"7204-0038": "8.6",
|
||||||
|
"7204-0055": "9.3",
|
||||||
|
"7204-0065": "9.4",
|
||||||
|
"7204-0160": "12.9",
|
||||||
|
}
|
||||||
|
#: **운전경비 줄이 원문에 없는 기계** — 손료만으로 층을 세움(부착 장비 길 · 2026-09-15 브레인 판정 ②′).
|
||||||
|
#: ⚠ 이 목록 밖으로 넓히지 말 것 — 연료가 빠진 채 조용히 싼 값이 섬. 줄마다 원문 없음 · 실무 실증을 적음.
|
||||||
|
LOSS_ONLY_MACHINES: dict[str, str] = {
|
||||||
|
"7750-0016": (
|
||||||
|
"손료만 — 건설품셈 8-4 운전경비표에 취부기(7750) 줄이 없음(원문 없음) · 거창 실무 「취부기(녹생토)"
|
||||||
|
" 11.94kW」 도 손료 20,295 만 · ⚠ 거창은 디젤엔진 11.19kW 를 따로 세움 — 산림 5-24 · 건설 4-1-3"
|
||||||
|
" 표 둘 다에 없어 안 넣음"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
_WATER_TANK_MISC_PERCENT = Decimal(30)
|
||||||
|
_WATER_TANK_OPERATORS = Decimal(1)
|
||||||
|
|
||||||
|
|
||||||
class OperatingCostError(ValueError):
|
class OperatingCostError(ValueError):
|
||||||
"""운전경비 표를 못 읽은 경우. 어긋난 채로 짝짓지 않는다."""
|
"""운전경비 표를 못 읽은 경우. 어긋난 채로 짝짓지 않는다."""
|
||||||
|
|
||||||
@@ -278,6 +305,20 @@ def load_operating_records(
|
|||||||
aliases = _read_json(*_CATALOG_SUBPATH, labor_file)["variables"].get("aliases", {})
|
aliases = _read_json(*_CATALOG_SUBPATH, labor_file)["variables"].get("aliases", {})
|
||||||
catalog = load_machine_catalog()
|
catalog = load_machine_catalog()
|
||||||
parsed = parse_operating_tables(pum, aliases, set(catalog.machines))
|
parsed = parse_operating_tables(pum, aliases, set(catalog.machines))
|
||||||
|
read = {record.machine_code for record in parsed.records}
|
||||||
|
for code, liters in MERGED_CELL_OPERATING_FIXES.items():
|
||||||
|
if code not in read:
|
||||||
|
parsed.records.append(
|
||||||
|
OperatingRecord(
|
||||||
|
machine_code=code,
|
||||||
|
machine_name="",
|
||||||
|
specification="",
|
||||||
|
fuel_liters_per_hour=Decimal(liters),
|
||||||
|
fuel_kind="경유",
|
||||||
|
misc_material_percent=_WATER_TANK_MISC_PERCENT,
|
||||||
|
operator_person_days=_WATER_TANK_OPERATORS,
|
||||||
|
)
|
||||||
|
)
|
||||||
return enrich_with_catalog(parsed, catalog, aliases)
|
return enrich_with_catalog(parsed, catalog, aliases)
|
||||||
|
|
||||||
|
|
||||||
@@ -316,9 +357,14 @@ def write_operating_records(
|
|||||||
#: 시도별 유가 판 — 품셈 8-1-7 5호 「유류가격은 **해당지역의 가격**으로 한다」.
|
#: 시도별 유가 판 — 품셈 8-1-7 5호 「유류가격은 **해당지역의 가격**으로 한다」.
|
||||||
#: ⚠ **파일이 있을 때만 지역을 고를 수 있다** — 없으면 전국평균 한 벌로 돈다(코드로 막지 않음).
|
#: ⚠ **파일이 있을 때만 지역을 고를 수 있다** — 없으면 전국평균 한 벌로 돈다(코드로 막지 않음).
|
||||||
REGIONAL_OIL_FILE = "oil_regional_2026-09-09.json"
|
REGIONAL_OIL_FILE = "oil_regional_2026-09-09.json"
|
||||||
|
#: 운전경비표 연료 종류 → 유가 판 변수. ⚠ 종류를 안 읽고 경유로 때우면 휘발유 기계가 싸게 섬
|
||||||
|
#: (2026-09-14 661 뒤 ② — 플레이트 콤팩터·진동기·믹서·래머·커터가 경유값이었음).
|
||||||
|
FUEL_VARIABLES = {"경유": "oil_diesel", "휘발유": "oil_gasoline"}
|
||||||
|
|
||||||
|
|
||||||
def load_regional_fuel_table(oil_file: str = REGIONAL_OIL_FILE) -> tuple[dict[str, dict], dict]:
|
def load_regional_fuel_table(
|
||||||
|
oil_file: str = REGIONAL_OIL_FILE, kind: str = "경유"
|
||||||
|
) -> tuple[dict[str, dict], dict]:
|
||||||
"""(시도코드 → {이름·값}, 판 신원). 판이 없으면 **빈 표**를 돌려준다.
|
"""(시도코드 → {이름·값}, 판 신원). 판이 없으면 **빈 표**를 돌려준다.
|
||||||
|
|
||||||
⚠ 원문에 있는 코드 `00`(전국)은 **지역 선택지에서 뺀다** — 그 자리는 전국평균 판이
|
⚠ 원문에 있는 코드 `00`(전국)은 **지역 선택지에서 뺀다** — 그 자리는 전국평균 판이
|
||||||
@@ -328,7 +374,7 @@ def load_regional_fuel_table(oil_file: str = REGIONAL_OIL_FILE) -> tuple[dict[st
|
|||||||
payload = _read_json(*_CATALOG_SUBPATH, oil_file)
|
payload = _read_json(*_CATALOG_SUBPATH, oil_file)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return {}, {}
|
return {}, {}
|
||||||
diesel = payload["variables"]["oil_diesel"]
|
diesel = payload["variables"][FUEL_VARIABLES[kind]]
|
||||||
table = {
|
table = {
|
||||||
str(record["sido_code"]): {
|
str(record["sido_code"]): {
|
||||||
"name": str(record["sido_name"]),
|
"name": str(record["sido_name"]),
|
||||||
@@ -346,7 +392,7 @@ def load_regional_fuel_table(oil_file: str = REGIONAL_OIL_FILE) -> tuple[dict[st
|
|||||||
|
|
||||||
|
|
||||||
def load_fuel_price(
|
def load_fuel_price(
|
||||||
oil_file: str = "oil_2026-08-14.json", region: str | None = None
|
oil_file: str = "oil_2026-08-14.json", region: str | None = None, kind: str = "경유"
|
||||||
) -> tuple[Decimal, dict[str, str]]:
|
) -> tuple[Decimal, dict[str, str]]:
|
||||||
"""경유 단가와 그 판의 신원. `region`(시도코드)을 주면 **그 지역 값**으로 선다.
|
"""경유 단가와 그 판의 신원. `region`(시도코드)을 주면 **그 지역 값**으로 선다.
|
||||||
|
|
||||||
@@ -355,7 +401,7 @@ def load_fuel_price(
|
|||||||
⚠ 준 지역이 판에 없으면 **조용히 전국평균으로 눕지 않고** 그 사실을 신원에 적는다.
|
⚠ 준 지역이 판에 없으면 **조용히 전국평균으로 눕지 않고** 그 사실을 신원에 적는다.
|
||||||
"""
|
"""
|
||||||
payload = _read_json(*_CATALOG_SUBPATH, oil_file)
|
payload = _read_json(*_CATALOG_SUBPATH, oil_file)
|
||||||
diesel = payload["variables"]["oil_diesel"]
|
diesel = payload["variables"][FUEL_VARIABLES[kind]]
|
||||||
meta = {
|
meta = {
|
||||||
"dataset_id": payload.get("dataset_id", ""),
|
"dataset_id": payload.get("dataset_id", ""),
|
||||||
"effective_date": payload.get("effective_date", ""),
|
"effective_date": payload.get("effective_date", ""),
|
||||||
@@ -366,7 +412,7 @@ def load_fuel_price(
|
|||||||
if not region:
|
if not region:
|
||||||
return Decimal(str(diesel["value"])), meta
|
return Decimal(str(diesel["value"])), meta
|
||||||
|
|
||||||
table, region_meta = load_regional_fuel_table()
|
table, region_meta = load_regional_fuel_table(kind=kind)
|
||||||
picked = table.get(str(region))
|
picked = table.get(str(region))
|
||||||
if picked is None:
|
if picked is None:
|
||||||
meta["region"] = str(region)
|
meta["region"] = str(region)
|
||||||
@@ -429,10 +475,12 @@ def hourly_cost_of(machine_code: str, *, region: str | None = None):
|
|||||||
if record is None:
|
if record is None:
|
||||||
return hourly_machine_cost(machine)
|
return hourly_machine_cost(machine)
|
||||||
|
|
||||||
fuel_price, _ = load_fuel_price(region=region)
|
|
||||||
wages = load_operator_wages()
|
wages = load_operator_wages()
|
||||||
|
|
||||||
liters = record.fuel_liters_per_hour
|
liters = record.fuel_liters_per_hour
|
||||||
|
fuel_price = (
|
||||||
|
None if liters is None else load_fuel_price(region=region, kind=record.fuel_kind)[0]
|
||||||
|
)
|
||||||
if liters is not None and record.misc_material_percent is not None:
|
if liters is not None and record.misc_material_percent is not None:
|
||||||
# 잡재료는 **주연료의 %** 라 유가와 같이 움직인다(PLAN 8-18 유가 민감분).
|
# 잡재료는 **주연료의 %** 라 유가와 같이 움직인다(PLAN 8-18 유가 민감분).
|
||||||
liters = liters * (Decimal(1) + record.misc_material_percent / Decimal(100))
|
liters = liters * (Decimal(1) + record.misc_material_percent / Decimal(100))
|
||||||
|
|||||||
@@ -170,6 +170,15 @@ def dump_title_code(work_item_code: str, distance_m: Decimal) -> str:
|
|||||||
return f"B-{work_item_code}#L{distance_m.normalize():f}m"
|
return f"B-{work_item_code}#L{distance_m.normalize():f}m"
|
||||||
|
|
||||||
|
|
||||||
|
def distance_label(distance_m: Decimal) -> str:
|
||||||
|
"""보이는 운반거리 「L=137.13m」 — 소수 둘째 자리까지(㉰ 2026-09-14 · 가중평균이 14자리로 떴음).
|
||||||
|
|
||||||
|
⚠ **보이기만** 줄임 — 호표 열쇠(`dump_title_code`)·운반 식은 받은 거리 그대로 씀.
|
||||||
|
"""
|
||||||
|
shown = Decimal(distance_m).quantize(Decimal("0.01")).normalize()
|
||||||
|
return f"L={shown:f}m"
|
||||||
|
|
||||||
|
|
||||||
def attach_dump_hauls(build: Any, master: dict[str, Any], distances_m: tuple[Decimal, ...]) -> None:
|
def attach_dump_hauls(build: Any, master: dict[str, Any], distances_m: tuple[Decimal, ...]) -> None:
|
||||||
"""거리마다 덤프 운반 일위대가를 세운다 — X(덤프트럭 15ton) → D → B.
|
"""거리마다 덤프 운반 일위대가를 세운다 — X(덤프트럭 15ton) → D → B.
|
||||||
|
|
||||||
@@ -226,7 +235,7 @@ def attach_dump_hauls(build: Any, master: dict[str, Any], distances_m: tuple[Dec
|
|||||||
kind=PriceKind.UNIT_PRICE,
|
kind=PriceKind.UNIT_PRICE,
|
||||||
name=f"{names.get(DUMP_PARENT) or '덤프운반'} "
|
name=f"{names.get(DUMP_PARENT) or '덤프운반'} "
|
||||||
f"{names.get(code) or material.label}",
|
f"{names.get(code) or material.label}",
|
||||||
spec=f"L={distance.normalize():f}m",
|
spec=distance_label(distance),
|
||||||
unit="㎥",
|
unit="㎥",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -186,7 +186,9 @@ def _assemble(build: Any, node: dict[str, Any], names: dict[str, str]) -> None:
|
|||||||
return
|
return
|
||||||
per_step.append((step, weight, titles))
|
per_step.append((step, weight, titles))
|
||||||
varying = [item for item in per_step if set(item[2]) - {""}]
|
varying = [item for item in per_step if set(item[2]) - {""}]
|
||||||
if len(varying) > 1:
|
# 갈래가 같은 이름으로 여러 단계에 걸치면 갈래끼리 합산(유로폼 12-38 = 사용수량 + 설치·해체
|
||||||
|
# 둘 다 간단·보통·복잡 · 2026-09-14 301). 갈래 벌이 다르면 짝을 못 지어 종전대로 막음.
|
||||||
|
if len({frozenset(set(ts) - {""}) for _, _, ts in varying}) > 1:
|
||||||
build.component_gaps[parent] = "갈래가 두 단계 이상에 걸쳐 조립하지 않았습니다"
|
build.component_gaps[parent] = "갈래가 두 단계 이상에 걸쳐 조립하지 않았습니다"
|
||||||
return
|
return
|
||||||
units = {build.book.titles[t].unit for _, _, ts in per_step for t in ts.values()} - {""}
|
units = {build.book.titles[t].unit for _, _, ts in per_step for t in ts.values()} - {""}
|
||||||
|
|||||||
@@ -27,6 +27,16 @@ from dataclasses import dataclass, field, replace
|
|||||||
from decimal import Decimal, InvalidOperation
|
from decimal import Decimal, InvalidOperation
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
# 셀 거르기 · 물결표 목록 · 셀 정규화 — 700줄 제한으로 `_Labels` 에 둠(순수 분리 2026-09-14).
|
||||||
|
# 바깥 파일이 여기서 가져가는 이름은 그대로 다시 내보낸다.
|
||||||
|
from B09_Estimation.B09_Estimation_ResourceAxis_Labels import (
|
||||||
|
_RE_RANGE_CELL, # noqa: F401
|
||||||
|
RANGE_DASH_CLASS, # noqa: F401
|
||||||
|
RANGE_DASHES, # noqa: F401
|
||||||
|
_normalize,
|
||||||
|
is_non_resource_label,
|
||||||
|
)
|
||||||
|
|
||||||
#: 자원 축을 붙일 수 있는 표 형태. 나머지는 값을 쓰지 않는다.
|
#: 자원 축을 붙일 수 있는 표 형태. 나머지는 값을 쓰지 않는다.
|
||||||
USABLE_FORMS = frozenset({"productivity", "requirement"})
|
USABLE_FORMS = frozenset({"productivity", "requirement"})
|
||||||
#: 공종이 아닌 표 — 일위대가 항목으로 세우지 않는다.
|
#: 공종이 아닌 표 — 일위대가 항목으로 세우지 않는다.
|
||||||
@@ -48,45 +58,6 @@ _RE_NUMBER = re.compile(r"^-?\d+(?:,\d{3})*(?:\.\d+)?$")
|
|||||||
#: 보통인부 한 줄만 남았다).
|
#: 보통인부 한 줄만 남았다).
|
||||||
_GROUP_LABELS = ("자재", "장비", "인력", "노무", "재료", "기계")
|
_GROUP_LABELS = ("자재", "장비", "인력", "노무", "재료", "기계")
|
||||||
|
|
||||||
#: 표 머리글·소계 행의 첫 칸에 오는 말. 자원이 아니므로 `unmatched` 로도 안 올린다.
|
|
||||||
#: 이것을 안 거르면 못 맞춘 목록이 머리글로 가득 차 **쓸 수 없는 목록**이 된다.
|
|
||||||
#: ⚠ **부분일치로 보면 안 된다.** 「계」를 부분일치로 잡으면 `건설기계운전사`·`비계공`·
|
|
||||||
#: `계장공` 이, 「작업」을 잡으면 `작업반장` 이, 「인력」을 잡으면 `인력운반공` 이
|
|
||||||
#: 통째로 사라진다(2026-09-07 실측 — 정상 자원 **70/745** 가 걸리고 있었음).
|
|
||||||
#: 그래서 **셀 전체가 그 말과 같을 때만** 머리글로 본다.
|
|
||||||
_NON_RESOURCE_WORDS = (
|
|
||||||
"구분",
|
|
||||||
"합계",
|
|
||||||
"소계",
|
|
||||||
"계",
|
|
||||||
"단위",
|
|
||||||
"비고",
|
|
||||||
"규격",
|
|
||||||
"명칭",
|
|
||||||
"품명",
|
|
||||||
"종류",
|
|
||||||
"항목",
|
|
||||||
"적용",
|
|
||||||
"기준",
|
|
||||||
"산출",
|
|
||||||
"비율",
|
|
||||||
"할증",
|
|
||||||
"할인",
|
|
||||||
"직접노무비",
|
|
||||||
"재료비",
|
|
||||||
"경비",
|
|
||||||
"위치",
|
|
||||||
"면적",
|
|
||||||
"수량",
|
|
||||||
"공종",
|
|
||||||
"작업",
|
|
||||||
"내역",
|
|
||||||
"총계",
|
|
||||||
"인력",
|
|
||||||
"장비",
|
|
||||||
"기계",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ResourceAxisError(ValueError):
|
class ResourceAxisError(ValueError):
|
||||||
"""자원 축을 붙일 수 없는 경우. 조용히 넘기지 않는다."""
|
"""자원 축을 붙일 수 없는 경우. 조용히 넘기지 않는다."""
|
||||||
@@ -151,71 +122,6 @@ class ResourceCatalog:
|
|||||||
return narrowed[0] if len(narrowed) == 1 else None
|
return narrowed[0] if len(narrowed) == 1 else None
|
||||||
|
|
||||||
|
|
||||||
#: 첫 칸이 자원 이름이 **아닌** 표가 많다 — 규격 구간표(「10∼12」), 기호표(「f」·「E」),
|
|
||||||
#: 치수표 등. 그런 셀을 못 맞춘 목록에 넣으면 목록이 못 쓰게 되므로 먼저 거른다.
|
|
||||||
#: ⚠ **물결표·붙임표 목록은 여기 한 벌뿐이다.** 네 파일에 따로 적어 두었더니 서로
|
|
||||||
#: 달라졌다(2026-09-08 메인 창 교차검토 — 어떤 목록엔 `~`, 어떤 목록엔 `〜` 가 빠졌음).
|
|
||||||
#: 지금 물리는 것은 없었으나 **같은 목록이 네 벌이면 언젠가 하나만 고쳐진다.**
|
|
||||||
#: 갈래 키 정규화(`B09_Estimation_UnitPrice.normalize_variant_key`)도 이 목록을 쓴다.
|
|
||||||
RANGE_DASHES = "∼~〜~-–‐"
|
|
||||||
|
|
||||||
#: ⚠ **문자클래스에 그대로 넣지 말 것** — `~-–` 이 **범위 연산자**로 읽혀 거의 모든
|
|
||||||
#: 글자가 걸린다(2026-09-08 실측: 「0.7㎥」·「15톤」이 구간으로 잡혔음). 반드시 이 쪽을 쓴다.
|
|
||||||
RANGE_DASH_CLASS = "".join(re.escape(ch) for ch in RANGE_DASHES)
|
|
||||||
|
|
||||||
#: 구간 셀에는 **단위 꼬리**가 붙기도 한다 — 「51~100m」·「12~14㎝」(2026-09-08 실측 39줄).
|
|
||||||
#: ⚠ **단위가 붙었다고 다 구간이 아니다** — 「굴착기 0.7㎥」는 규격이고 자원 이름의 일부다.
|
|
||||||
#: 그래서 **수 ~ 수 + 단위**라는 모양 전체가 맞을 때만 구간으로 본다(앞에 이름이 없어야 한다).
|
|
||||||
_RANGE_UNITS = "a-zA-Z㎝㎜㎥㎡㎞mm톤"
|
|
||||||
_RE_RANGE_CELL = re.compile(
|
|
||||||
rf"^\d+(?:\.\d+)?\s*[{RANGE_DASH_CLASS}]\s*\d+(?:\.\d+)?\s*[{_RANGE_UNITS}]+$"
|
|
||||||
)
|
|
||||||
_RE_HANGUL = re.compile(r"[가-힣]")
|
|
||||||
|
|
||||||
|
|
||||||
def is_non_resource_label(cell: str) -> bool:
|
|
||||||
"""표 머리글·소계 행이거나, 애초에 자원 이름이 올 자리가 아닌 셀인가.
|
|
||||||
|
|
||||||
못 맞춘 목록에 이런 것이 섞이면 목록 자체가 못 쓰게 된다. 여기서 먼저 걷어낸다.
|
|
||||||
"""
|
|
||||||
text = _normalize(cell)
|
|
||||||
if not text:
|
|
||||||
return True
|
|
||||||
if text.startswith(("※", "<", "(", "-", "ㆍ", "·")):
|
|
||||||
return True
|
|
||||||
if _RE_RANGE_CELL.match(text): # 규격 구간표의 첫 칸
|
|
||||||
return True
|
|
||||||
# 자원 이름은 숫자로 시작하지 않는다 — 「50이상」·「100m이하」·「2.집재」는 구간·절번호다.
|
|
||||||
if text[0].isdigit():
|
|
||||||
return True
|
|
||||||
# 자원 이름은 한글 두 자 이상이다. 기호(`f`·`E`)·숫자·단위만 있는 칸은 자원이 아니다.
|
|
||||||
if len(_RE_HANGUL.findall(text)) < 2:
|
|
||||||
return True
|
|
||||||
# ⚠ **정확 일치만** — 부분일치는 정상 자원을 통째로 지운다(위 주석).
|
|
||||||
if text in _NON_RESOURCE_WORDS:
|
|
||||||
return True
|
|
||||||
# 머리글 조각이 이어 붙은 칸(「단위작업별」·「위치및면적」)도 머리글이다.
|
|
||||||
return _is_header_composite(text)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_header_composite(text: str) -> bool:
|
|
||||||
"""머리글 낱말만으로 이루어진 칸인가 — 「단위작업별」·「위치및면적」 같은 것.
|
|
||||||
|
|
||||||
낱말을 차례로 벗겨 아무것도 안 남으면 머리글로 본다. 자원 이름은 낱말을 벗기면
|
|
||||||
반드시 무언가 남는다(`건설기계운전사` → `건설`·`운전사`).
|
|
||||||
"""
|
|
||||||
rest = text
|
|
||||||
for word in sorted(_NON_RESOURCE_WORDS, key=len, reverse=True):
|
|
||||||
rest = rest.replace(word, "")
|
|
||||||
rest = rest.replace("및", "").replace("별", "").strip()
|
|
||||||
return rest == ""
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize(text: str) -> str:
|
|
||||||
"""표 셀의 공백·개행 흔들림을 지운다. 「경 암」·「연 암」 같은 것."""
|
|
||||||
return re.sub(r"\s+", "", str(text or "")).strip()
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ResourceRow:
|
class ResourceRow:
|
||||||
"""자원 축 한 줄 — 공종(표) 하나에 붙는 자원 하나."""
|
"""자원 축 한 줄 — 공종(표) 하나에 붙는 자원 하나."""
|
||||||
@@ -320,6 +226,28 @@ def _tidy_resource_name(cell: str) -> str:
|
|||||||
_MACHINE_UNITS = ("시간", "hr", "h", "대", "시 간")
|
_MACHINE_UNITS = ("시간", "hr", "h", "대", "시 간")
|
||||||
|
|
||||||
|
|
||||||
|
#: 조용히 빠지던 기계·연료 줄의 사유(2026-09-15 ㉠ — 인력만으로 싸게 서던 7공종).
|
||||||
|
SILENT_MACHINE = "기계·연료 줄이 안 붙음 — 이 공종 단가는 일부만 섬(인력만으로 싸게 서지 않게 막음)"
|
||||||
|
#: 규격이 이름 앞에 온 장비 칸 — 「0.8㎥ 굴착기」.
|
||||||
|
_RE_SPEC_FIRST = re.compile(r"^\d+(?:\.\d+)?(?:㎥|m3|ton|톤|㎾|kW)\s*[가-힣]{2,}")
|
||||||
|
#: 연료·기관 줄 이름 — 값이 비었거나 식이라도 기계 몫(연료비 · 휘발유 · 경유 · 엔진 · 기관).
|
||||||
|
_RE_FUEL_OR_ENGINE = re.compile(r"연료|휘발유|경유|엔진|기관|양수기|발전기|원동기")
|
||||||
|
_POWER_UNITS = ("kW", "㎾", "HP", "PS", "마력")
|
||||||
|
|
||||||
|
|
||||||
|
def _silent_machine_row(name_cell: str, value_cells: list[str], has_digit: bool) -> bool:
|
||||||
|
"""값을 못 읽고 이름도 안 풀린 줄이 **기계·연료 몫**인가 — 머리 줄·설명 줄은 아님."""
|
||||||
|
name = _normalize(name_cell)
|
||||||
|
if not name or is_non_resource_label(name_cell) and not _RE_FUEL_OR_ENGINE.search(name):
|
||||||
|
return False
|
||||||
|
if _RE_FUEL_OR_ENGINE.search(name):
|
||||||
|
return True
|
||||||
|
joined = " ".join(value_cells)
|
||||||
|
return has_digit and (
|
||||||
|
_is_machine_like_row(value_cells) or any(unit in joined for unit in _POWER_UNITS)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _is_machine_like_row(cells: list[str]) -> bool:
|
def _is_machine_like_row(cells: list[str]) -> bool:
|
||||||
"""그 줄이 **장비 몫**인가 — 단위 칸이 시간·대수인지로 본다."""
|
"""그 줄이 **장비 몫**인가 — 단위 칸이 시간·대수인지로 본다."""
|
||||||
for cell in cells:
|
for cell in cells:
|
||||||
@@ -442,6 +370,7 @@ def match_table(
|
|||||||
|
|
||||||
# 사람이 모양을 읽어 둔 표(9-19-1 절토면·성토면 · 2026-09-14 판정) — 적어 둔 표만.
|
# 사람이 모양을 읽어 둔 표(9-19-1 절토면·성토면 · 2026-09-14 판정) — 적어 둔 표만.
|
||||||
from B09_Estimation.B09_Estimation_ResourceAxis_JudgedTable import match_judged_table
|
from B09_Estimation.B09_Estimation_ResourceAxis_JudgedTable import match_judged_table
|
||||||
|
|
||||||
if match_judged_table(node, table, catalog, result, basis_quantity, unit):
|
if match_judged_table(node, table, catalog, result, basis_quantity, unit):
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -573,6 +502,17 @@ def match_table(
|
|||||||
reason="자원은 알아봤으나 값을 못 읽었습니다 — 단가가 일부만 섭니다.",
|
reason="자원은 알아봤으나 값을 못 읽었습니다 — 단가가 일부만 섭니다.",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
elif _silent_machine_row(name_cell, value_cells, has_digit):
|
||||||
|
# 이름도 값도 못 읽은 기계·연료 줄 — 조용히 넘기면 인력만으로 싸게 섬(2026-09-15 ㉠).
|
||||||
|
code = node.get("work_item_code", "")
|
||||||
|
result.partial_items[code] = (
|
||||||
|
f"{_normalize(name_cell)[:20]} (기계·연료 줄)이 안 붙음"
|
||||||
|
)
|
||||||
|
result.unmatched.append(
|
||||||
|
UnmatchedRow(
|
||||||
|
code, str(table.get("pum_table_id", "")), name_cell, SILENT_MACHINE
|
||||||
|
)
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# ⚠ **카탈로그 조회를 먼저 한다.** 이름 필터를 앞에 두면 필터가 넓을 때
|
# ⚠ **카탈로그 조회를 먼저 한다.** 이름 필터를 앞에 두면 필터가 넓을 때
|
||||||
@@ -584,6 +524,17 @@ def match_table(
|
|||||||
name_cell = apply_scoped_alias(catalog, name_cell, node["work_item_code"], value_cells)
|
name_cell = apply_scoped_alias(catalog, name_cell, node["work_item_code"], value_cells)
|
||||||
entry = entry or _resolve_cell(catalog, name_cell, [name_cell, *value_cells])
|
entry = entry or _resolve_cell(catalog, name_cell, [name_cell, *value_cells])
|
||||||
if entry is None:
|
if entry is None:
|
||||||
|
if _RE_SPEC_FIRST.match(_normalize(name_cell)) and _is_machine_like_row(value_cells):
|
||||||
|
# 「0.8㎥ 굴착기 | h | 0.25」 — 규격이 이름 앞이라 머리글로 걸러지던 장비 줄(14-2 · ㉠).
|
||||||
|
result.partial_items[node["work_item_code"]] = (
|
||||||
|
f"{_normalize(name_cell)[:20]} (기계·연료 줄)이 안 붙음"
|
||||||
|
)
|
||||||
|
result.unmatched.append(
|
||||||
|
UnmatchedRow(
|
||||||
|
node["work_item_code"], table["pum_table_id"], name_cell, SILENT_MACHINE
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
if is_non_resource_label(name_cell):
|
if is_non_resource_label(name_cell):
|
||||||
continue # 머리글·소계 — 못 맞춘 목록에도 안 올린다
|
continue # 머리글·소계 — 못 맞춘 목록에도 안 올린다
|
||||||
# 「규격 미정 — 후보 N」 · 「같은 이름 여럿」 · 「카탈로그에 없는 이름」을 가른다.
|
# 「규격 미정 — 후보 N」 · 「같은 이름 여럿」 · 「카탈로그에 없는 이름」을 가른다.
|
||||||
|
|||||||
@@ -128,14 +128,20 @@ def scoped_alias_entry(
|
|||||||
흐려짐 — 별칭이 코드를 적었으면 그 코드가 곧 답(2026-09-14 · 9-19-3 소형브레이커).
|
흐려짐 — 별칭이 코드를 적었으면 그 코드가 곧 답(2026-09-14 · 9-19-3 소형브레이커).
|
||||||
⚠ 별칭은 **빈 곳을 채움** — 옆 칸이 규격을 적었으면 안 덮음(같은 9-19-1 성토면 「굴착기 |
|
⚠ 별칭은 **빈 곳을 채움** — 옆 칸이 규격을 적었으면 안 덮음(같은 9-19-1 성토면 「굴착기 |
|
||||||
0.6㎥」 가 절토면 [주]① 0.7 로 덮이던 자리 · 2026-09-14 판정 Ⓐ).
|
0.6㎥」 가 절토면 [주]① 0.7 로 덮이던 자리 · 2026-09-14 판정 Ⓐ).
|
||||||
|
단, 대상 규격이 옆 칸 규격과 **같으면** 덮는 것이 아님 — 이름만 이음(5-24 「트럭 | 4.5ton」 →
|
||||||
|
덤프트럭 4.5 · 2026-09-15 판정 ①).
|
||||||
"""
|
"""
|
||||||
if _writes_spec(side_cells):
|
|
||||||
return None
|
|
||||||
wanted = _normalize(name_cell)
|
wanted = _normalize(name_cell)
|
||||||
for row in catalog.scoped_aliases:
|
for row in catalog.scoped_aliases:
|
||||||
if _normalize(row["from"]) != wanted or not in_scope(work_item_code, row["scope"]):
|
if _normalize(row["from"]) != wanted or not in_scope(work_item_code, row["scope"]):
|
||||||
continue
|
continue
|
||||||
return next((entry for entry in catalog.entries if entry.code == row["to"]), None)
|
entry = next((entry for entry in catalog.entries if entry.code == row["to"]), None)
|
||||||
|
if entry is not None and _writes_spec(side_cells):
|
||||||
|
same = any(
|
||||||
|
_RE_SIDE_SPEC.match(_normalize(c)) and same_spec(entry.spec, c) for c in side_cells
|
||||||
|
)
|
||||||
|
return entry if same else None
|
||||||
|
return entry
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
F0288 1. 절토면 고르기 자원 이름이 **첫 자료 줄**(「보통인부 (인)」 …) · 규격은 [주]① 에만
|
F0288 1. 절토면 고르기 자원 이름이 **첫 자료 줄**(「보통인부 (인)」 …) · 규격은 [주]① 에만
|
||||||
· 「·」 = 그 토질엔 그 자원 없음
|
· 「·」 = 그 토질엔 그 자원 없음
|
||||||
F0289 2. 성토면 고르기 병합 첫 칸(시공) 탓에 둘째 줄이 한 칸 앞당겨 옴
|
F0289 2. 성토면 고르기 병합 첫 칸(시공) 탓에 둘째 줄이 한 칸 앞당겨 옴
|
||||||
|
F0336 12-4 합판거푸집 기준수량(1회) × 사용횟수별 비율 — 재료 줄은 재료 %, 인력 줄은 노무비 %
|
||||||
|
(원문 L6187 · 2026-09-14 브레인 ㉮ — 비율 셈이 없어 막아 뒀던 표)
|
||||||
|
|
||||||
⚠ 일반 보정으로 넓히지 않음 — 밀린 줄 고쳐 읽기는 목재틀흙막이 503만원 전례(`_Transposed` 머리말).
|
⚠ 일반 보정으로 넓히지 않음 — 밀린 줄 고쳐 읽기는 목재틀흙막이 503만원 전례(`_Transposed` 머리말).
|
||||||
**적어 둔 표만** 읽고, 칸이 판정과 다르면 한 줄도 안 세우고 막음.
|
**적어 둔 표만** 읽고, 칸이 판정과 다르면 한 줄도 안 세우고 막음.
|
||||||
@@ -47,7 +49,123 @@ JUDGED_TABLES: dict[str, dict[str, Any]] = {
|
|||||||
"form_split": {"굴착기": ("무한궤도", "타이어")},
|
"form_split": {"굴착기": ("무한궤도", "타이어")},
|
||||||
"why": "원문 L5430 「2. 성토면 고르기 (단위: 10㎡당)」 — 시공 칸 병합으로 둘째 줄 앞당김",
|
"why": "원문 L5430 「2. 성토면 고르기 (단위: 10㎡당)」 — 시공 칸 병합으로 둘째 줄 앞당김",
|
||||||
},
|
},
|
||||||
|
"F0336": {
|
||||||
|
"code": "FP-12-04",
|
||||||
|
"shape": "use_count",
|
||||||
|
"prefix": "합판거푸집",
|
||||||
|
"why": "원문 L6187 「기준수량(1회사용) · 사용횟수별기준수량에대한 비율(%) 재료별·노무비」",
|
||||||
|
},
|
||||||
|
"F0353": {
|
||||||
|
"code": "FP-12-15",
|
||||||
|
"shape": "remark_labor",
|
||||||
|
"prefix": "집수정",
|
||||||
|
# 구체콘크리트는 바로 아래 다짐기 줄과 한 갈래 — 다짐기가 안 풀리면 갈래를 안 세움(⑴).
|
||||||
|
"needs_machine": {"구체콘크리트": "다짐:봉상후렉시블(45mm)"},
|
||||||
|
"why": "원문 L6460 「구체·버림콘크리트 ㎥ — 비고 칸 콘크리트공·보통인부 인/㎥」",
|
||||||
|
},
|
||||||
|
# 12-15 와 같은 모양 셋 — 같은 봉상후렉시블 줄 하나가 셋을 막고 있었음(2026-09-14 브레인 · 672 다음).
|
||||||
|
"F0350": {
|
||||||
|
"code": "FP-12-12",
|
||||||
|
"shape": "remark_labor",
|
||||||
|
"prefix": "날개벽",
|
||||||
|
"needs_machine": {"콘크리트": "다짐:봉상후렉시블(45mm)"},
|
||||||
|
"why": "원문 L6419 12-12 「콘크리트(레미콘) ㎥ — 비고 칸 콘크리트공·보통인부 인/㎥」",
|
||||||
|
},
|
||||||
|
"F0351": {
|
||||||
|
"code": "FP-12-13",
|
||||||
|
"shape": "remark_labor",
|
||||||
|
"prefix": "면벽",
|
||||||
|
"needs_machine": {"콘크리트": "다짐:봉상후렉시블(45mm)"},
|
||||||
|
"why": "원문 L6436 12-13 「콘크리트(레미콘) ㎥ — 비고 칸 콘크리트공·보통인부 인/㎥」",
|
||||||
|
},
|
||||||
|
"F0354": {
|
||||||
|
"code": "FP-12-16",
|
||||||
|
"shape": "remark_labor",
|
||||||
|
"prefix": "맨홀",
|
||||||
|
# 칸이 하나 밀려 비고가 끝 칸이 아님(「구체콘크리트 | 철근 | ㎥ | | 비고 | 」).
|
||||||
|
"needs_machine": {"구체콘크리트": "봉상후렉시블(45mm)"},
|
||||||
|
"why": "원문 L6474 12-16 「구체·버림콘크리트 ㎥ — 비고 칸 콘크리트공·보통인부 인/㎥」",
|
||||||
|
},
|
||||||
|
# 12-34-1 — 머리 「(단위: 개소당)」 이나 인력 0.17·0.29 가 12-16 맨홀 ㎥당과 같고 기계가 Q ㎥/hr
|
||||||
|
# ⇒ ㎥당으로 읽음 · 「봉상후렉시블 대 2」 는 엔진식 진동기(엔진+플렉시블 한 대)의 봉(2026-09-14 브레인).
|
||||||
|
"F0385": {
|
||||||
|
"code": "FP-12-34-01",
|
||||||
|
"shape": "per_m3_rows",
|
||||||
|
"prefix": "콘크리트 타설",
|
||||||
|
# 엔진식 진동기(건설품셈 8-3 (4611) 엔진+플렉시블 한 대)의 봉 — 「진동기(3.5HP) 대 2」 로 셈.
|
||||||
|
"same_machine": ("봉상후렉시블(45mm)",),
|
||||||
|
"why": "원문 L6839 12-34-1 「인력 콘크리트공·보통인부 인 · 기계 대 (Q=5.4㎥/hr)」",
|
||||||
|
},
|
||||||
|
# 시간 줄 표 — 장비·인력이 다 「h」(2026-09-15 브레인 ㉡-3). 굴착기 형식을 원문이 안 적어 두 갈래 · 제안 없음(①).
|
||||||
|
"F0450": {
|
||||||
|
"code": "FP-14-01",
|
||||||
|
"shape": "hour_rows",
|
||||||
|
"prefix": "입목뿌리 밑막이",
|
||||||
|
"forms": (("무한궤도", "0201-0080"), ("타이어", "0211-0080")),
|
||||||
|
# 원문 「우드그랩 부착」 ↔ 카탈로그 「부착용 집게 0.6∼0.8」 — 이름이 다름(②).
|
||||||
|
"attachment": "7206-0070",
|
||||||
|
"why": "원문 L7798 14-1 「0.8㎥ 굴착기(우드그랩 부착) h · 보통인부 h」(10본당)",
|
||||||
|
},
|
||||||
|
"F0451": {
|
||||||
|
"code": "FP-14-02",
|
||||||
|
"shape": "hour_rows",
|
||||||
|
"prefix": "근주이식",
|
||||||
|
"forms": (("무한궤도", "0201-0080"), ("타이어", "0211-0080")),
|
||||||
|
"why": "원문 L7809 14-2 「0.8㎥ 굴착기 h · 보통인부 h」(10본당)",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
#: 총칙 L530 「본 품셈에서 제시된 품은 일일 작업시간 8시간을 기준」 — 인력 시간 ÷ 8 = 인.
|
||||||
|
HOURS_PER_DAY = Decimal(8)
|
||||||
|
_RE_SPEC_FIRST_MACHINE = re.compile(r"^\d+(?:\.\d+)?㎥굴착기")
|
||||||
|
#: 줄 첫 칸이 분류 딱지인 표(12-34-1 「자재 | 콘크리트(레미콘)」) — 이름은 다음 칸.
|
||||||
|
_ROW_CATEGORIES = ("자재", "인력", "기계")
|
||||||
|
#: 자원이 아닌 머리 줄(12-04 「횟수별 | 재료별(%) | 노무비(%)」).
|
||||||
|
_HEADER_ROWS = ("횟수별", "구분")
|
||||||
|
UNREAD_REASON = "판정표가 안 읽은 줄 — 이 일위대가에 안 넣음(자동 · 2026-09-14)"
|
||||||
|
|
||||||
|
|
||||||
|
def _loose(text: str) -> str:
|
||||||
|
"""겹침 비교용 — 빈칸·괄호·가운뎃점·쉼표를 뺌(「적사(굴착기 0.7㎥)」 ↔ 「적사 굴착기 0.7㎥」)."""
|
||||||
|
return re.sub(r"[\s()·,:]", "", str(text))
|
||||||
|
|
||||||
|
|
||||||
|
def _unread_rows(code, table_id, judged, rows, staged) -> list:
|
||||||
|
"""읽힌 줄 밖의 줄을 「못 붙은 줄」 로 — 표를 넣을 때마다 손으로 사유를 안 달아도 안 샘.
|
||||||
|
|
||||||
|
㉠ 빼는 것: 읽힌 줄(`raw_row_index`) · 이미 못 맞춤으로 선 이름 · 다른 갈래가 쓴 기계 줄
|
||||||
|
(`needs_machine`·`same_machine`) · 자원 머리(`header_row` 첫 줄 · 「횟수별」) · 빈 줄
|
||||||
|
㉡ 손 사유(`known_gap_note`)가 이미 적은 이름은 안 올림 — 같은 말이 두 번 안 뜨게
|
||||||
|
"""
|
||||||
|
from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note
|
||||||
|
|
||||||
|
read = {item.raw_row_index for item in staged if isinstance(item, ResourceRow)}
|
||||||
|
taken = {_loose(item.cell) for item in staged if isinstance(item, UnmatchedRow)}
|
||||||
|
taken |= {_loose(name) for name in judged.get("needs_machine", {}).values()}
|
||||||
|
taken |= {_loose(name) for name in judged.get("same_machine", ())}
|
||||||
|
hand = _loose(known_gap_note(code))
|
||||||
|
unread: list = []
|
||||||
|
for index, cells in enumerate(rows):
|
||||||
|
cells = [c for c in cells]
|
||||||
|
if index in read or not any(cells) or (judged["shape"] == "header_row" and index == 0):
|
||||||
|
continue
|
||||||
|
name = cells[1] if cells[0] in _ROW_CATEGORIES and len(cells) > 1 else cells[0]
|
||||||
|
key = _loose(name)
|
||||||
|
if not key or key in _HEADER_ROWS or key in taken or key in hand:
|
||||||
|
continue
|
||||||
|
if key == "비고":
|
||||||
|
name = f"비고 — {' '.join(' '.join(cells[1:]).split())[:40]}…"
|
||||||
|
unread.append(UnmatchedRow(code, table_id, " ".join(name.split()), UNREAD_REASON))
|
||||||
|
taken.add(key)
|
||||||
|
return unread
|
||||||
|
|
||||||
|
|
||||||
|
#: 비고 칸 인력 — 「콘크리트공0.24인/㎥, 보통인부 0.42인/㎥」.
|
||||||
|
_RE_REMARK_LABOR = re.compile(r"([가-힣]+)\s*(\d+(?:\.\d+)?)\s*인/㎥")
|
||||||
|
_RE_Q = re.compile(r"Q\s*=\s*(\d+(?:\.\d+)?)")
|
||||||
|
#: 비율 줄 — 「1회사용시 2회사용시 …」 칸.
|
||||||
|
_RE_USE_COUNT = re.compile(r"(\d+)회사용시")
|
||||||
|
#: 값으로 안 읽는 줄 — 사용고재 평가기준(원문이 셈을 안 줌 · 사유는 `KNOWN_GAPS`) · 비고.
|
||||||
|
_NOT_RESOURCE_ROWS = ("사용고재평가기준", "비고", "횟수별")
|
||||||
|
|
||||||
#: 「보통인부 (인)」 의 단위 꼬리 — 규격이 아님.
|
#: 「보통인부 (인)」 의 단위 꼬리 — 규격이 아님.
|
||||||
_UNIT_TAIL = re.compile(r"\s*[((](?:인|시간)[))]\s*$")
|
_UNIT_TAIL = re.compile(r"\s*[((](?:인|시간)[))]\s*$")
|
||||||
@@ -99,14 +217,24 @@ def match_judged_table(
|
|||||||
result.partial_items[code] = why
|
result.partial_items[code] = why
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if basis_quantity in (None, 0):
|
# 비고·Q 가 ㎥당을 적는 모양은 표 머리 밑수를 안 씀(12-12 날개벽은 「개소당」 머리조차 없음).
|
||||||
|
if basis_quantity in (None, 0) and judged["shape"] not in ("remark_labor", "per_m3_rows"):
|
||||||
return block("판정 표에 밑수가 없습니다")
|
return block("판정 표에 밑수가 없습니다")
|
||||||
if judged["shape"] == "header_row":
|
if judged["shape"] == "header_row":
|
||||||
staged = _header_row(code, table, judged, rows, catalog, basis_quantity, unit)
|
staged = _header_row(code, table, judged, rows, catalog, basis_quantity, unit)
|
||||||
|
elif judged["shape"] == "use_count":
|
||||||
|
staged = _use_count(code, table, rows, catalog, basis_quantity, unit)
|
||||||
|
elif judged["shape"] == "remark_labor":
|
||||||
|
staged = _remark_labor(code, table, judged, rows, catalog)
|
||||||
|
elif judged["shape"] == "hour_rows":
|
||||||
|
staged = _hour_rows(code, table, judged, rows, catalog, basis_quantity)
|
||||||
|
elif judged["shape"] == "per_m3_rows":
|
||||||
|
staged = _per_m3_rows(code, table, judged, rows, catalog)
|
||||||
else:
|
else:
|
||||||
staged = _merged_first(code, table, judged, rows, catalog, basis_quantity, unit)
|
staged = _merged_first(code, table, judged, rows, catalog, basis_quantity, unit)
|
||||||
if isinstance(staged, str):
|
if isinstance(staged, str):
|
||||||
return block(f"{staged} — 판정({judged['why']})과 칸이 달라 안 읽음")
|
return block(f"{staged} — 판정({judged['why']})과 칸이 달라 안 읽음")
|
||||||
|
staged = [*staged, *_unread_rows(code, table_id, judged, rows, staged)]
|
||||||
for item in staged:
|
for item in staged:
|
||||||
if isinstance(item, UnmatchedRow):
|
if isinstance(item, UnmatchedRow):
|
||||||
result.unmatched.append(item)
|
result.unmatched.append(item)
|
||||||
@@ -176,3 +304,134 @@ def _merged_first(code, table, judged, rows, catalog, basis, unit) -> list | str
|
|||||||
named = f"{variant} · {form}" if form else variant
|
named = f"{variant} · {form}" if form else variant
|
||||||
staged.append(_row(code, table, entry, amount / basis, unit, index, named))
|
staged.append(_row(code, table, entry, amount / basis, unit, index, named))
|
||||||
return staged
|
return staged
|
||||||
|
|
||||||
|
|
||||||
|
def _use_count(code, table, rows, catalog, basis, unit) -> list | str:
|
||||||
|
"""[이름, 단위, 기준수량, …] + 비율 줄 하나 → 사용횟수마다 갈래(재료 % · 노무비 %)."""
|
||||||
|
ratio_row = next((cells for cells in rows if _RE_USE_COUNT.search(" ".join(cells))), None)
|
||||||
|
if ratio_row is None:
|
||||||
|
return "사용횟수 비율 줄"
|
||||||
|
counts = _RE_USE_COUNT.findall(" ".join(ratio_row))
|
||||||
|
number = re.compile(r"\d+(?:\.\d+)?")
|
||||||
|
material = [Decimal(x) for x in number.findall(ratio_row[4] if len(ratio_row) > 4 else "")]
|
||||||
|
labor = [Decimal(x) for x in number.findall(ratio_row[5] if len(ratio_row) > 5 else "")]
|
||||||
|
if not counts or not (len(counts) == len(material) == len(labor)):
|
||||||
|
return f"비율 칸 수(횟수 {len(counts)} · 재료 {len(material)} · 노무비 {len(labor)})"
|
||||||
|
staged: list = []
|
||||||
|
for index, cells in enumerate(rows):
|
||||||
|
name = "".join(cells[0].split()) if cells else ""
|
||||||
|
base = parse_amount(cells[2]) if len(cells) > 2 else None
|
||||||
|
if not name or name in _NOT_RESOURCE_ROWS or base is None:
|
||||||
|
continue
|
||||||
|
entry = _entry(catalog, cells[0], code)
|
||||||
|
if entry is None:
|
||||||
|
reason = unmatched_reason(catalog, cells[0])
|
||||||
|
staged.append(UnmatchedRow(code, str(table.get("pum_table_id", "")), cells[0], reason))
|
||||||
|
continue
|
||||||
|
ratios = labor if entry.kind == "labor" else material
|
||||||
|
for count, ratio in zip(counts, ratios):
|
||||||
|
amount = base * ratio / Decimal(100) / basis
|
||||||
|
staged.append(_row(code, table, entry, amount, unit, index, f"{count}회"))
|
||||||
|
return staged
|
||||||
|
|
||||||
|
|
||||||
|
def _remark_labor(code, table, judged, rows, catalog) -> list | str:
|
||||||
|
"""㎥ 줄 비고 칸의 인력(인/㎥)으로 갈래 — 다짐기가 딸린 갈래는 그 기계가 풀려야 세움."""
|
||||||
|
table_id = str(table.get("pum_table_id", ""))
|
||||||
|
by_name = {"".join(cells[0].split()): cells for cells in rows if cells}
|
||||||
|
staged: list = []
|
||||||
|
for index, cells in enumerate(rows):
|
||||||
|
labors = _RE_REMARK_LABOR.findall(" ".join(cells[3:])) # 비고가 끝 칸이 아닌 표(12-16)
|
||||||
|
if len(cells) < 3 or cells[2] != "㎥" or not labors:
|
||||||
|
continue
|
||||||
|
variant = cells[0].split("(")[0].strip()
|
||||||
|
pieces = []
|
||||||
|
for name, amount in labors:
|
||||||
|
entry = _entry(catalog, name, code)
|
||||||
|
if entry is None:
|
||||||
|
return f"{variant} 인력 「{name}」"
|
||||||
|
pieces.append((entry, Decimal(amount)))
|
||||||
|
machine_name = judged.get("needs_machine", {}).get(variant)
|
||||||
|
if machine_name:
|
||||||
|
machine_cells = by_name.get("".join(machine_name.split())) or []
|
||||||
|
found_q = _RE_Q.search(" ".join(machine_cells))
|
||||||
|
entry = _entry(catalog, machine_name, code) if machine_cells else None
|
||||||
|
if entry is None or found_q is None:
|
||||||
|
reason = unmatched_reason(catalog, machine_name)
|
||||||
|
staged.append(UnmatchedRow(code, table_id, machine_name, reason))
|
||||||
|
why = (
|
||||||
|
f"다짐기 「{machine_name}」 가 안 풀려 갈래를 안 세움 — 인력만이면 조립 줄이"
|
||||||
|
" 조용히 싸짐(2026-09-14 ㉯ ⑴)"
|
||||||
|
)
|
||||||
|
staged.append(UnmatchedRow(code, table_id, variant, why))
|
||||||
|
continue
|
||||||
|
pieces.append((entry, Decimal(1) / Decimal(found_q.group(1))))
|
||||||
|
for entry, amount in pieces:
|
||||||
|
staged.append(_row(code, table, entry, amount, "㎥", index, variant))
|
||||||
|
return staged
|
||||||
|
|
||||||
|
|
||||||
|
def _per_m3_rows(code, table, judged, rows, catalog) -> list | str:
|
||||||
|
"""「인」 칸 앞 이름 · 뒤 수(인/㎥) · 「대」 칸 앞 이름 · 뒤 대수 ÷ Q — 칸이 밀린 줄도 단위 칸으로 찾음."""
|
||||||
|
staged: list = []
|
||||||
|
for index, cells in enumerate(rows):
|
||||||
|
unit = next((i for i, c in enumerate(cells) if c in ("인", "대") and i > 0), None)
|
||||||
|
if unit is None:
|
||||||
|
continue
|
||||||
|
name = cells[unit - 1]
|
||||||
|
if "".join(name.split()) in judged.get("same_machine", {}):
|
||||||
|
continue # 같은 기계 두 번 안 셈 — 까닭은 공종 사유 한 줄(`KnownGaps`)이 화면에 보임
|
||||||
|
amount = next((parse_amount(c) for c in cells[unit + 1 :] if parse_amount(c)), None)
|
||||||
|
entry = _entry(catalog, name, code)
|
||||||
|
if amount is None or entry is None:
|
||||||
|
return f"{name} 줄"
|
||||||
|
if cells[unit] == "대":
|
||||||
|
found_q = _RE_Q.search(" ".join(cells))
|
||||||
|
if found_q is None:
|
||||||
|
return f"{name} Q"
|
||||||
|
amount = amount / Decimal(found_q.group(1))
|
||||||
|
staged.append(_row(code, table, entry, amount, "㎥", index, ""))
|
||||||
|
return staged
|
||||||
|
|
||||||
|
|
||||||
|
def _hour_rows(code, table, judged, rows, catalog, basis_quantity) -> list | str:
|
||||||
|
"""장비·인력이 다 시간인 표 — 장비 h 그대로 · 인력 h ÷ 8 · ÷ 밑수 · 굴착기 형식마다 갈래."""
|
||||||
|
entries = {entry.code: entry for entry in catalog.entries}
|
||||||
|
unit = str(table.get("basis_unit") or "")
|
||||||
|
machine: tuple[int, Decimal] | None = None
|
||||||
|
labor: list = []
|
||||||
|
for index, cells in enumerate(rows):
|
||||||
|
if len(cells) < 3 or "".join(cells[1].split()) != "h":
|
||||||
|
continue
|
||||||
|
hours = parse_amount(cells[2])
|
||||||
|
if hours is None:
|
||||||
|
return f"{cells[0]} 시간"
|
||||||
|
if _RE_SPEC_FIRST_MACHINE.match("".join(cells[0].split())):
|
||||||
|
machine = (index, hours)
|
||||||
|
continue
|
||||||
|
entry = _entry(catalog, cells[0], code)
|
||||||
|
if entry is None:
|
||||||
|
return f"{cells[0]} 줄"
|
||||||
|
labor.append((index, entry, hours / HOURS_PER_DAY))
|
||||||
|
if machine is None:
|
||||||
|
return "장비 줄"
|
||||||
|
staged: list = []
|
||||||
|
for form, machine_code in judged["forms"]:
|
||||||
|
codes = [machine_code, *([judged["attachment"]] if judged.get("attachment") else [])]
|
||||||
|
if any(c not in entries for c in codes):
|
||||||
|
return f"{form} 기종 코드"
|
||||||
|
for machine_code_ in codes:
|
||||||
|
staged.append(
|
||||||
|
_row(
|
||||||
|
code,
|
||||||
|
table,
|
||||||
|
entries[machine_code_],
|
||||||
|
machine[1] / basis_quantity,
|
||||||
|
unit,
|
||||||
|
machine[0],
|
||||||
|
form,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for index, entry, amount in labor:
|
||||||
|
staged.append(_row(code, table, entry, amount / basis_quantity, unit, index, form))
|
||||||
|
return staged
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""B09 원가계산 — 자원 축 **셀 거르기 · 물결표 목록 · 셀 정규화** (`_ResourceAxis` 보조).
|
||||||
|
|
||||||
|
`B09_Estimation_ResourceAxis` 가 700줄 제한에 걸려 떼어 낸 파일이다(2026-09-14 순수 분리 —
|
||||||
|
내용은 옮기기만 함). 바깥 파일은 여태처럼 `B09_Estimation_ResourceAxis` 에서 가져간다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
#: 표 머리글·소계 행의 첫 칸에 오는 말. 자원이 아니므로 `unmatched` 로도 안 올린다.
|
||||||
|
#: 이것을 안 거르면 못 맞춘 목록이 머리글로 가득 차 **쓸 수 없는 목록**이 된다.
|
||||||
|
#: ⚠ **부분일치로 보면 안 된다.** 「계」를 부분일치로 잡으면 `건설기계운전사`·`비계공`·
|
||||||
|
#: `계장공` 이, 「작업」을 잡으면 `작업반장` 이, 「인력」을 잡으면 `인력운반공` 이
|
||||||
|
#: 통째로 사라진다(2026-09-07 실측 — 정상 자원 **70/745** 가 걸리고 있었음).
|
||||||
|
#: 그래서 **셀 전체가 그 말과 같을 때만** 머리글로 본다.
|
||||||
|
_NON_RESOURCE_WORDS = (
|
||||||
|
"구분",
|
||||||
|
"합계",
|
||||||
|
"소계",
|
||||||
|
"계",
|
||||||
|
"단위",
|
||||||
|
"비고",
|
||||||
|
"규격",
|
||||||
|
"명칭",
|
||||||
|
"품명",
|
||||||
|
"종류",
|
||||||
|
"항목",
|
||||||
|
"적용",
|
||||||
|
"기준",
|
||||||
|
"산출",
|
||||||
|
"비율",
|
||||||
|
"할증",
|
||||||
|
"할인",
|
||||||
|
"직접노무비",
|
||||||
|
"재료비",
|
||||||
|
"경비",
|
||||||
|
"위치",
|
||||||
|
"면적",
|
||||||
|
"수량",
|
||||||
|
"공종",
|
||||||
|
"작업",
|
||||||
|
"내역",
|
||||||
|
"총계",
|
||||||
|
"인력",
|
||||||
|
"장비",
|
||||||
|
"기계",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
#: 첫 칸이 자원 이름이 **아닌** 표가 많다 — 규격 구간표(「10∼12」), 기호표(「f」·「E」),
|
||||||
|
#: 치수표 등. 그런 셀을 못 맞춘 목록에 넣으면 목록이 못 쓰게 되므로 먼저 거른다.
|
||||||
|
#: ⚠ **물결표·붙임표 목록은 여기 한 벌뿐이다.** 네 파일에 따로 적어 두었더니 서로
|
||||||
|
#: 달라졌다(2026-09-08 메인 창 교차검토 — 어떤 목록엔 `~`, 어떤 목록엔 `〜` 가 빠졌음).
|
||||||
|
#: 지금 물리는 것은 없었으나 **같은 목록이 네 벌이면 언젠가 하나만 고쳐진다.**
|
||||||
|
#: 갈래 키 정규화(`B09_Estimation_UnitPrice.normalize_variant_key`)도 이 목록을 쓴다.
|
||||||
|
RANGE_DASHES = "∼~〜~-–‐"
|
||||||
|
|
||||||
|
#: ⚠ **문자클래스에 그대로 넣지 말 것** — `~-–` 이 **범위 연산자**로 읽혀 거의 모든
|
||||||
|
#: 글자가 걸린다(2026-09-08 실측: 「0.7㎥」·「15톤」이 구간으로 잡혔음). 반드시 이 쪽을 쓴다.
|
||||||
|
RANGE_DASH_CLASS = "".join(re.escape(ch) for ch in RANGE_DASHES)
|
||||||
|
|
||||||
|
#: 구간 셀에는 **단위 꼬리**가 붙기도 한다 — 「51~100m」·「12~14㎝」(2026-09-08 실측 39줄).
|
||||||
|
#: ⚠ **단위가 붙었다고 다 구간이 아니다** — 「굴착기 0.7㎥」는 규격이고 자원 이름의 일부다.
|
||||||
|
#: 그래서 **수 ~ 수 + 단위**라는 모양 전체가 맞을 때만 구간으로 본다(앞에 이름이 없어야 한다).
|
||||||
|
_RANGE_UNITS = "a-zA-Z㎝㎜㎥㎡㎞mm톤"
|
||||||
|
_RE_RANGE_CELL = re.compile(
|
||||||
|
rf"^\d+(?:\.\d+)?\s*[{RANGE_DASH_CLASS}]\s*\d+(?:\.\d+)?\s*[{_RANGE_UNITS}]+$"
|
||||||
|
)
|
||||||
|
_RE_HANGUL = re.compile(r"[가-힣]")
|
||||||
|
|
||||||
|
|
||||||
|
def is_non_resource_label(cell: str) -> bool:
|
||||||
|
"""표 머리글·소계 행이거나, 애초에 자원 이름이 올 자리가 아닌 셀인가.
|
||||||
|
|
||||||
|
못 맞춘 목록에 이런 것이 섞이면 목록 자체가 못 쓰게 된다. 여기서 먼저 걷어낸다.
|
||||||
|
"""
|
||||||
|
text = _normalize(cell)
|
||||||
|
if not text:
|
||||||
|
return True
|
||||||
|
if text.startswith(("※", "<", "(", "-", "ㆍ", "·")):
|
||||||
|
return True
|
||||||
|
if _RE_RANGE_CELL.match(text): # 규격 구간표의 첫 칸
|
||||||
|
return True
|
||||||
|
# 자원 이름은 숫자로 시작하지 않는다 — 「50이상」·「100m이하」·「2.집재」는 구간·절번호다.
|
||||||
|
if text[0].isdigit():
|
||||||
|
return True
|
||||||
|
# 자원 이름은 한글 두 자 이상이다. 기호(`f`·`E`)·숫자·단위만 있는 칸은 자원이 아니다.
|
||||||
|
if len(_RE_HANGUL.findall(text)) < 2:
|
||||||
|
return True
|
||||||
|
# ⚠ **정확 일치만** — 부분일치는 정상 자원을 통째로 지운다(위 주석).
|
||||||
|
if text in _NON_RESOURCE_WORDS:
|
||||||
|
return True
|
||||||
|
# 머리글 조각이 이어 붙은 칸(「단위작업별」·「위치및면적」)도 머리글이다.
|
||||||
|
return _is_header_composite(text)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_header_composite(text: str) -> bool:
|
||||||
|
"""머리글 낱말만으로 이루어진 칸인가 — 「단위작업별」·「위치및면적」 같은 것.
|
||||||
|
|
||||||
|
낱말을 차례로 벗겨 아무것도 안 남으면 머리글로 본다. 자원 이름은 낱말을 벗기면
|
||||||
|
반드시 무언가 남는다(`건설기계운전사` → `건설`·`운전사`).
|
||||||
|
"""
|
||||||
|
rest = text
|
||||||
|
for word in sorted(_NON_RESOURCE_WORDS, key=len, reverse=True):
|
||||||
|
rest = rest.replace(word, "")
|
||||||
|
rest = rest.replace("및", "").replace("별", "").strip()
|
||||||
|
return rest == ""
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize(text: str) -> str:
|
||||||
|
"""표 셀의 공백·개행 흔들림을 지운다. 「경 암」·「연 암」 같은 것."""
|
||||||
|
return re.sub(r"\s+", "", str(text or "")).strip()
|
||||||
@@ -46,6 +46,8 @@ def _normalize_label(text: str) -> str:
|
|||||||
|
|
||||||
#: 「계」 열 — **가공 + 조립을 이미 더한 값**이다. 같이 읽으면 두 번 센다(㉤ 열 방향).
|
#: 「계」 열 — **가공 + 조립을 이미 더한 값**이다. 같이 읽으면 두 번 센다(㉤ 열 방향).
|
||||||
_SUM_GROUP_LABELS = ("계", "합계", "소계", "총계")
|
_SUM_GROUP_LABELS = ("계", "합계", "소계", "총계")
|
||||||
|
#: 갈래 이름에 적힌 제 밑수 — 「대형헬기 (400ha당)」.
|
||||||
|
_RE_VARIANT_BASIS = re.compile(r"\(\s*(\d[\d,]*(?:\.\d+)?)\s*(?:ha|㏊|㎡|㎥|m|본|개소)\s*당\s*\)")
|
||||||
|
|
||||||
|
|
||||||
def _sum_group_positions(headers: list, resource_count: int) -> set:
|
def _sum_group_positions(headers: list, resource_count: int) -> set:
|
||||||
@@ -143,6 +145,7 @@ def _match_two_row_table(
|
|||||||
unit: str,
|
unit: str,
|
||||||
ordinal: list,
|
ordinal: list,
|
||||||
skip_rows: int,
|
skip_rows: int,
|
||||||
|
basis_quantity: Decimal | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""2단 표 — **숫자 칸을 순서대로** 자원에 맞춘다.
|
"""2단 표 — **숫자 칸을 순서대로** 자원에 맞춘다.
|
||||||
|
|
||||||
@@ -180,9 +183,15 @@ def _match_two_row_table(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
# ⚠ 밑수로 나눔 — 갈래 이름이 「(400ha당)」 처럼 제 밑수를 적으면 그것으로(8-6-1 유인헬기가
|
||||||
|
# 160배·400배 부풀어 있던 자리 · 2026-09-15). 표 밑수가 한 벌뿐이라 갈래 밑수를 못 가르던 병.
|
||||||
|
found = _RE_VARIANT_BASIS.search(variant)
|
||||||
|
divisor = Decimal(found.group(1).replace(",", "")) if found else basis_quantity
|
||||||
for (order, entry, blocked), amount in zip(ordinal, numbers):
|
for (order, entry, blocked), amount in zip(ordinal, numbers):
|
||||||
if blocked:
|
if blocked:
|
||||||
continue # 「계」 묶음 — 이미 더한 값이다
|
continue # 「계」 묶음 — 이미 더한 값이다
|
||||||
|
if divisor not in (None, 0, Decimal(1)):
|
||||||
|
amount = amount / divisor
|
||||||
result.rows.append(
|
result.rows.append(
|
||||||
ResourceRow(
|
ResourceRow(
|
||||||
work_item_code=work_item_code,
|
work_item_code=work_item_code,
|
||||||
@@ -497,7 +506,9 @@ def match_transposed_table(
|
|||||||
# 자원 이름이 **둘째 줄**에 오는 2단 표일 수 있다.
|
# 자원 이름이 **둘째 줄**에 오는 2단 표일 수 있다.
|
||||||
ordinal, skip_rows = second_row_columns(table, catalog)
|
ordinal, skip_rows = second_row_columns(table, catalog)
|
||||||
if ordinal:
|
if ordinal:
|
||||||
return _match_two_row_table(node, table, catalog, result, unit, ordinal, skip_rows)
|
return _match_two_row_table(
|
||||||
|
node, table, catalog, result, unit, ordinal, skip_rows, basis_quantity
|
||||||
|
)
|
||||||
if not columns:
|
if not columns:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""B09 원가계산 — **암석 작업 기계손료 보정** (건설품셈 8-1-7 1 · 2026-09-14 브레인 661 ①②).
|
||||||
|
|
||||||
|
원문: 「다음 건설기계가 암석굴착, 암석적재, 암석운반 등의 가혹한 작업에 사용되는 경우에는
|
||||||
|
손료(관리비 제외)를 다음과 같이 보정 가산한다」 — 불도저(19톤 이상 제외) 25 · 굴착기(무한궤도)
|
||||||
|
및 로더(무한궤도) 20 · 덤프트럭 25 (%) · [주]① 전용덤프트럭(18톤 이상)과 불도저(19톤 이상)는
|
||||||
|
보정하지 않는다(타이어·습지 불도저는 보정). 율은 `mach_base` 의 `mach_rock_adj`(원문 파싱) 한 벌.
|
||||||
|
|
||||||
|
암석 손료계수 = (상각 + 정비) × (1 + 가산) + 관리 — 1e-7 정수 아래 버림
|
||||||
|
실무 봉화 2024 「(암석)」 줄 셋이 그대로 역산됨(굴착기 1.0 0.2405 · 덤프 2.5 0.3533 · 덤프 15 0.2679)
|
||||||
|
|
||||||
|
거는 자리 암 공종(자기·부모 이름이나 갈래가 연암·보통암·경암·발파암·파쇄암·암절취·암석)의
|
||||||
|
대상 기계 줄만 — 기계 호표를 「암석」 한 벌 더 세워 부름(봉화와 같은 모양)
|
||||||
|
안 거는 것 브레이커 조합 본체(`#조합`) — 봉화 「굴삭기 0.7 브레이커조합」 손료가 비암석 23,128 + 브레이커
|
||||||
|
풍화암·호박돌 섞인 토사 — 원문 표 「암석작업(연암·보통암·경암)」 밖
|
||||||
|
전석섞인토사 10% — 혼입율(0.5㎥ 이상 전석 30% 이상) 입력이 없어 판정 못 함(② · 칸 안 만듦)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import replace
|
||||||
|
from decimal import ROUND_FLOOR, Decimal
|
||||||
|
from functools import lru_cache
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
ROCK_SUFFIX = "#암석"
|
||||||
|
_ROCK_WORDS = re.compile(r"연암|보통암|경암|발파암|파쇄암|암절취|암석")
|
||||||
|
_E7 = Decimal("1e-7")
|
||||||
|
NOT_CORRECTED = "8-1-7 [주]① {what} 은 암석 손료보정 안 함"
|
||||||
|
|
||||||
|
|
||||||
|
def _tight(text: Any) -> str:
|
||||||
|
return re.sub(r"\s", "", str(text or ""))
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def _sources() -> tuple[dict[str, dict[str, Any]], dict[str, int]]:
|
||||||
|
"""(기계 코드 → 손료 성분 레코드, 규칙 이름 → 암석 가산 %)."""
|
||||||
|
from B09_Estimation.B09_Estimation_MachineCost import _read_json
|
||||||
|
|
||||||
|
variables = _read_json("mach_base_2026.json")["variables"]
|
||||||
|
records = {r["machine_code"]: r for r in variables["mach_loss_coef"]["records"]}
|
||||||
|
rules = {r["machine_group"]: int(r["rock_work"]) for r in variables["mach_rock_adj"]["rules"]}
|
||||||
|
return records, rules
|
||||||
|
|
||||||
|
|
||||||
|
def rock_rate(code: str) -> tuple[int | None, str]:
|
||||||
|
"""(가산 %, 안 거는 까닭) — 표에 없는 기종은 `(None, "")`."""
|
||||||
|
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
|
||||||
|
|
||||||
|
machine = load_machine_catalog().machines.get(code)
|
||||||
|
if machine is None:
|
||||||
|
return None, ""
|
||||||
|
_, rules = _sources()
|
||||||
|
name = _tight(machine.name)
|
||||||
|
size = re.match(r"\d+(?:\.\d+)?", machine.specification.replace(",", ""))
|
||||||
|
tons = Decimal(size.group()) if size else Decimal(0)
|
||||||
|
if name in ("불도저(타이어)", "습지불도저"):
|
||||||
|
return rules["bulldozer_under_19_ton"], ""
|
||||||
|
if name == "불도저(무한궤도)":
|
||||||
|
if tons >= 19:
|
||||||
|
return None, NOT_CORRECTED.format(what="불도저 19톤 이상")
|
||||||
|
return rules["bulldozer_under_19_ton"], ""
|
||||||
|
if name in ("굴착기(무한궤도)", "로더(무한궤도)"):
|
||||||
|
return rules["crawler_excavator_or_loader"], ""
|
||||||
|
if name == "덤프트럭":
|
||||||
|
if tons >= 18:
|
||||||
|
return None, NOT_CORRECTED.format(what="덤프트럭 18톤 이상")
|
||||||
|
return rules["dump_truck"], ""
|
||||||
|
return None, ""
|
||||||
|
|
||||||
|
|
||||||
|
def rock_parts(code: str) -> tuple[Decimal, Decimal, Decimal, int] | None:
|
||||||
|
"""(보정 상각, 보정 정비, 관리, 계) — 1e-7 단위 · 계는 정수 아래 버림(봉화 3533.75 → 3533)."""
|
||||||
|
rate, _ = rock_rate(code)
|
||||||
|
record = _sources()[0].get(code)
|
||||||
|
if rate is None or record is None:
|
||||||
|
return None
|
||||||
|
depreciation, maintenance, management = (
|
||||||
|
Decimal(str(record[f"{key}_coefficient_1e_minus_7"]))
|
||||||
|
for key in ("depreciation", "maintenance", "management")
|
||||||
|
)
|
||||||
|
factor = 1 + Decimal(rate) / 100
|
||||||
|
raised = (depreciation * factor, maintenance * factor, management)
|
||||||
|
return (*raised, int(sum(raised).quantize(Decimal(1), rounding=ROUND_FLOOR)))
|
||||||
|
|
||||||
|
|
||||||
|
def rock_coefficient(code: str) -> Decimal | None:
|
||||||
|
"""암석 손료계수(원당) = (상각 + 정비) × (1 + 가산) + 관리 — `rock_parts` 의 계 × 1e-7."""
|
||||||
|
parts = rock_parts(code)
|
||||||
|
return None if parts is None else parts[3] * _E7
|
||||||
|
|
||||||
|
|
||||||
|
def _rock_hourly(book: Any, code: str) -> str | None:
|
||||||
|
"""`X-<코드>#암석` — 손료만 보정 계수로 바꾼 호표(연료·조종원·잡품은 본 호표 그대로)."""
|
||||||
|
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
|
||||||
|
from B09_Estimation.B09_Estimation_PriceBook import PriceKind, PriceTitle
|
||||||
|
from B09_Estimation.B09_Estimation_UnitPrice import _slots
|
||||||
|
|
||||||
|
hourly, rock = f"X-{code}", f"X-{code}{ROCK_SUFFIX}"
|
||||||
|
if rock in book.titles:
|
||||||
|
return rock
|
||||||
|
coefficient = rock_coefficient(code)
|
||||||
|
if coefficient is None or hourly not in book.titles:
|
||||||
|
return None
|
||||||
|
machine = load_machine_catalog().machines[code]
|
||||||
|
rate, _ = rock_rate(code)
|
||||||
|
base, rock_base = f"S-{code}", f"S-{code}{ROCK_SUFFIX}"
|
||||||
|
plain = book.titles[base]
|
||||||
|
book.add_title(
|
||||||
|
replace(
|
||||||
|
plain, code=rock_base, slots=_slots(machine.price_thousand_krw * 1000 * coefficient)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
title = book.titles[hourly]
|
||||||
|
book.add_title(
|
||||||
|
PriceTitle(
|
||||||
|
code=rock,
|
||||||
|
kind=PriceKind.MACHINE_HOURLY,
|
||||||
|
name=title.name,
|
||||||
|
spec=f"{title.spec} · 암석".strip(" ·"),
|
||||||
|
unit=title.unit,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
note = f"암석 작업 손료보정 — (상각 + 정비) × {100 + rate}% + 관리 = {coefficient} (건설품셈 8-1-7 1)"
|
||||||
|
for detail in book.details.get(hourly, []):
|
||||||
|
ref = {base: rock_base, hourly: rock}.get(detail.ref_code, detail.ref_code)
|
||||||
|
book.add_detail(
|
||||||
|
replace(
|
||||||
|
detail,
|
||||||
|
parent_code=rock,
|
||||||
|
ref_code=ref,
|
||||||
|
note=note if detail.ref_code == base else detail.note,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return rock
|
||||||
|
|
||||||
|
|
||||||
|
def attach_rock_loss(build: Any, master: dict[str, Any]) -> int:
|
||||||
|
"""암 공종의 대상 기계 줄을 암석 호표로 바꿔 닮 — 바꾼 줄 수. 조합 16% 바꿔 달기 **뒤**에 부름."""
|
||||||
|
nodes = {str(n.get("work_item_code")): n for n in master.get("work_items", [])}
|
||||||
|
book = build.book
|
||||||
|
changed = 0
|
||||||
|
for title_code in [code for code in book.titles if code.startswith("B-")]:
|
||||||
|
work_item, _, variant = title_code[2:].partition("#")
|
||||||
|
node = nodes.get(work_item) or {}
|
||||||
|
parent = nodes.get(str(node.get("parent_code"))) or {}
|
||||||
|
title = book.titles[title_code]
|
||||||
|
text = " ".join(map(str, (node.get("name"), parent.get("name"), title.name, variant)))
|
||||||
|
if not _ROCK_WORDS.search(text):
|
||||||
|
continue
|
||||||
|
own = book.details.get(title_code) or []
|
||||||
|
owners = [title_code, *(d.ref_code for d in own if d.ref_code.startswith("D-"))]
|
||||||
|
for owner in owners:
|
||||||
|
details = book.details.get(owner) or []
|
||||||
|
for index, detail in enumerate(details):
|
||||||
|
ref = detail.ref_code
|
||||||
|
if not ref.startswith("X-") or "#" in ref:
|
||||||
|
continue
|
||||||
|
rate, why = rock_rate(ref[2:])
|
||||||
|
if rate is None:
|
||||||
|
if why and why not in detail.note:
|
||||||
|
details[index] = replace(detail, note=f"{detail.note} · {why}".strip(" ·"))
|
||||||
|
continue
|
||||||
|
rock = _rock_hourly(book, ref[2:])
|
||||||
|
if rock:
|
||||||
|
details[index] = replace(detail, ref_code=rock)
|
||||||
|
changed += 1
|
||||||
|
return changed
|
||||||
@@ -87,8 +87,8 @@ def cost_from_bill(root: str, bill: dict[str, Any], direct: dict[str, Decimal]):
|
|||||||
async def get_cost_sheet(project_id: UUID, form: str = "general") -> JSONResponse:
|
async def get_cost_sheet(project_id: UUID, form: str = "general") -> JSONResponse:
|
||||||
"""원가계산서 한 장 — 서식 줄 · 기준 입력 선택지 · 상태줄.
|
"""원가계산서 한 장 — 서식 줄 · 기준 입력 선택지 · 상태줄.
|
||||||
|
|
||||||
`form` — 일반형식만 금액을 냄. 수공·실적일반·실적수공은 **서식 차례만**(요율표 미확보 줄은
|
`form` — 일반형식만 금액을 냄. 수공·실적일반·실적수공은 **서식 차례만**(실적형 임도 미적용 ·
|
||||||
막고 사유 · 0 원으로 안 채움).
|
수공 세부 경비 법정 요율 없음 줄은 막고 사유 · 0 원으로 안 채움).
|
||||||
"""
|
"""
|
||||||
from B09_Estimation.B09_Estimation_Router import get_bill
|
from B09_Estimation.B09_Estimation_Router import get_bill
|
||||||
from common_util.common_util_project_settings import estimation_settings
|
from common_util.common_util_project_settings import estimation_settings
|
||||||
|
|||||||
@@ -110,7 +110,9 @@ async def get_factor_choices(project_id: UUID) -> JSONResponse:
|
|||||||
from B09_Estimation.B09_Estimation_Transport import BASIS_TEXT as TRANSPORT_BASIS
|
from B09_Estimation.B09_Estimation_Transport import BASIS_TEXT as TRANSPORT_BASIS
|
||||||
from B09_Estimation.B09_Estimation_Transport import ROAD_CLASSES as TRANSPORT_ROADS
|
from B09_Estimation.B09_Estimation_Transport import ROAD_CLASSES as TRANSPORT_ROADS
|
||||||
from B09_Estimation.B09_Estimation_Transport import TRANSPORT_VARIANTS
|
from B09_Estimation.B09_Estimation_Transport import TRANSPORT_VARIANTS
|
||||||
|
from B09_Estimation.B09_Estimation_Transport import TRIPS_NOTE as TRANSPORT_TRIPS_NOTE
|
||||||
from B09_Estimation.B09_Estimation_Transport import WORK_ITEM_CODE as TRANSPORT_CODE
|
from B09_Estimation.B09_Estimation_Transport import WORK_ITEM_CODE as TRANSPORT_CODE
|
||||||
|
from B09_Estimation.B09_Estimation_Transport import parse_trips, transport_amount
|
||||||
|
|
||||||
# 「넣을 데가 있는가」 — 주재료비가 선 일위대가가 몇인지 세어 그대로 알린다.
|
# 「넣을 데가 있는가」 — 주재료비가 선 일위대가가 몇인지 세어 그대로 알린다.
|
||||||
# 지금은 사급 자재 단가가 미결(확정 5차 큰 것 8)이라 0 이 정상이다.
|
# 지금은 사급 자재 단가가 미결(확정 5차 큰 것 8)이라 0 이 정상이다.
|
||||||
@@ -133,10 +135,19 @@ async def get_factor_choices(project_id: UUID) -> JSONResponse:
|
|||||||
book = prices.book
|
book = prices.book
|
||||||
transport_notes = list(prices.transport_notes)
|
transport_notes = list(prices.transport_notes)
|
||||||
transport_prices = {}
|
transport_prices = {}
|
||||||
|
transport_amounts = {}
|
||||||
|
# 회수는 품셈이 안 정하는 설계 입력 — 비면 금액을 안 세우고 사유만 남긴다.
|
||||||
|
transport_trips = parse_trips(settings.get("transport_trips"))
|
||||||
for variant in TRANSPORT_VARIANTS:
|
for variant in TRANSPORT_VARIANTS:
|
||||||
code = f"B-{TRANSPORT_CODE}#{variant['key']}"
|
code = f"B-{TRANSPORT_CODE}#{variant['key']}"
|
||||||
if code in book.titles:
|
if code in book.titles:
|
||||||
transport_prices[variant["key"]] = f"{book.resolve(code).total:,.0f}"
|
unit_price = book.resolve(code).total
|
||||||
|
transport_prices[variant["key"]] = f"{unit_price:,.0f}"
|
||||||
|
amount = transport_amount(unit_price, transport_trips)
|
||||||
|
if amount is not None:
|
||||||
|
transport_amounts[variant["key"]] = f"{amount:,.0f}"
|
||||||
|
if transport_prices and transport_trips is None:
|
||||||
|
transport_notes.append(TRANSPORT_TRIPS_NOTE)
|
||||||
with_material = sum(
|
with_material = sum(
|
||||||
1
|
1
|
||||||
for unit_code, unit_title in book.titles.items()
|
for unit_code, unit_title in book.titles.items()
|
||||||
@@ -174,6 +185,8 @@ async def get_factor_choices(project_id: UUID) -> JSONResponse:
|
|||||||
"transport": {
|
"transport": {
|
||||||
"distance_km": str(settings.get("transport_distance_km") or ""),
|
"distance_km": str(settings.get("transport_distance_km") or ""),
|
||||||
"road": str(settings.get("transport_road") or ""),
|
"road": str(settings.get("transport_road") or ""),
|
||||||
|
"trips": str(settings.get("transport_trips") or ""),
|
||||||
|
"trips_note": TRANSPORT_TRIPS_NOTE,
|
||||||
"roads": [
|
"roads": [
|
||||||
{"key": row["key"], "label": row["label"]} for row in TRANSPORT_ROADS
|
{"key": row["key"], "label": row["label"]} for row in TRANSPORT_ROADS
|
||||||
],
|
],
|
||||||
@@ -182,6 +195,7 @@ async def get_factor_choices(project_id: UUID) -> JSONResponse:
|
|||||||
"key": variant["key"],
|
"key": variant["key"],
|
||||||
"label": variant["label"],
|
"label": variant["label"],
|
||||||
"unit_price_krw": transport_prices.get(variant["key"], ""),
|
"unit_price_krw": transport_prices.get(variant["key"], ""),
|
||||||
|
"amount_krw": transport_amounts.get(variant["key"], ""),
|
||||||
}
|
}
|
||||||
for variant in TRANSPORT_VARIANTS
|
for variant in TRANSPORT_VARIANTS
|
||||||
],
|
],
|
||||||
@@ -236,6 +250,7 @@ class FactorChoiceBody(BaseModel):
|
|||||||
#: 기계 수송 거리(㎞)·도로 구분 — **비면 수송비 줄이 안 선다.**
|
#: 기계 수송 거리(㎞)·도로 구분 — **비면 수송비 줄이 안 선다.**
|
||||||
transport_distance_km: str | None = None
|
transport_distance_km: str | None = None
|
||||||
transport_road: str | None = None
|
transport_road: str | None = None
|
||||||
|
transport_trips: str | None = None
|
||||||
#: 품 할인·할증(1-4) — 계열코드 → 고른 행. **빈 값이면 그 계열을 끄는 것.**
|
#: 품 할인·할증(1-4) — 계열코드 → 고른 행. **빈 값이면 그 계열을 끄는 것.**
|
||||||
labor_surcharge: dict[str, str] | None = None
|
labor_surcharge: dict[str, str] | None = None
|
||||||
|
|
||||||
@@ -312,6 +327,14 @@ async def put_factor_choices(project_id: UUID, body: FactorChoiceBody) -> JSONRe
|
|||||||
content={"status": "error", "message": f"원문에 없는 도로 구분입니다: {road}"},
|
content={"status": "error", "message": f"원문에 없는 도로 구분입니다: {road}"},
|
||||||
)
|
)
|
||||||
values["transport_road"] = road
|
values["transport_road"] = road
|
||||||
|
if body.transport_trips is not None:
|
||||||
|
from B09_Estimation.B09_Estimation_Transport import parse_trips
|
||||||
|
|
||||||
|
try:
|
||||||
|
trips = parse_trips(body.transport_trips)
|
||||||
|
except ValueError as exc:
|
||||||
|
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
||||||
|
values["transport_trips"] = "" if trips is None else str(trips)
|
||||||
if body.labor_surcharge is not None:
|
if body.labor_surcharge is not None:
|
||||||
from B09_Estimation.B09_Estimation_LaborSurcharge import parse_choices
|
from B09_Estimation.B09_Estimation_LaborSurcharge import parse_choices
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""B09 원가계산 — **초류종자살포 5-24 종자**(2026-09-15 브레인 판정 ④ · 화약류 길).
|
||||||
|
|
||||||
|
표 「종자 kg 0.025」 는 규격을 안 적음 — 자원 목록에 종자 후보가 넷(생태복원형 초본류·목본류 · 초본류 · 목본류)이라
|
||||||
|
조인 규칙(규격이 같아야)에 걸려 못 붙었음. 배합은 [주]①③ 이 현장 판단(도로비탈면 녹화 지침 · 방향·고도·계절)으로 넘김.
|
||||||
|
⇒ 넷 모두 「자재 단가」 탭에 칸이 서고 **설계자가 넣은 하나**가 표 수량으로 붙음 · 안 넣으면 「규격 미정」 ·
|
||||||
|
둘 이상 넣으면 고르지 않고 사유(임의로 규격을 고르지 않음).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
CODES = ("FP-05-24-01", "FP-05-24-02")
|
||||||
|
NAME = "종자"
|
||||||
|
#: (자원 목록 코드, 규격) — 자원 목록 `resource_catalog_ext` 의 종자 넷.
|
||||||
|
SEEDS: tuple[tuple[str, str], ...] = (
|
||||||
|
("AR-M-f89c57d4", "생태복원형,초본류"),
|
||||||
|
("AR-M-d52b8fc1", "생태복원형,목본류"),
|
||||||
|
("AR-M-8f852514", "초본류"),
|
||||||
|
("AR-M-e39ee36e", "목본류"),
|
||||||
|
)
|
||||||
|
SEED_MISSING = (
|
||||||
|
"종자 — 규격 미정(원문 표가 규격을 안 적음 · 후보 넷 "
|
||||||
|
+ " · ".join(spec for _, spec in SEEDS)
|
||||||
|
+ ") · 「자재 단가」 탭에서 하나를 넣으면 붙음"
|
||||||
|
)
|
||||||
|
SEED_TWO = "종자 — 후보가 둘 이상 들어옴({specs}) · 하나만 넣을 것"
|
||||||
|
|
||||||
|
|
||||||
|
def attach_seed_spray(build: Any, nodes_by_code: dict[str, dict[str, Any]]) -> None:
|
||||||
|
"""칸 넷을 세우고(`material_uses`) · 넣은 하나만 붙이고 · 없거나 둘이면 사유로 갈아 끼움."""
|
||||||
|
from B09_Estimation.B09_Estimation_Explosives import _table_amount
|
||||||
|
from B09_Estimation.B09_Estimation_PriceBook import PriceDetail
|
||||||
|
|
||||||
|
book = build.book
|
||||||
|
for code in CODES:
|
||||||
|
for material, _ in SEEDS:
|
||||||
|
uses = build.material_uses.setdefault(material, [])
|
||||||
|
if code not in uses:
|
||||||
|
uses.append(code)
|
||||||
|
labels = [
|
||||||
|
label for label in build.unattached.get(code) or [] if "".join(label.split()) != NAME
|
||||||
|
]
|
||||||
|
amount = _table_amount(nodes_by_code.get(code) or {}, NAME)
|
||||||
|
picked = [(material, spec) for material, spec in SEEDS if material in book.titles]
|
||||||
|
titles = [t for t in book.titles if t == f"B-{code}" or t.startswith(f"B-{code}#")]
|
||||||
|
if len(picked) == 1 and amount is not None and titles:
|
||||||
|
material, spec = picked[0]
|
||||||
|
for title in titles:
|
||||||
|
book.add_detail(
|
||||||
|
PriceDetail(title, material, amount, note=f"종자 {spec} — 설계자 선택")
|
||||||
|
)
|
||||||
|
elif len(picked) > 1:
|
||||||
|
labels.append(SEED_TWO.format(specs=" · ".join(spec for _, spec in picked)))
|
||||||
|
else:
|
||||||
|
labels.append(SEED_MISSING)
|
||||||
|
build.unattached[code] = labels
|
||||||
@@ -194,13 +194,27 @@ def safety_management_cost(
|
|||||||
두 대상액이 **다른 구간에 떨어질 수 있어** A 가 항상 작지는 않다. 두 줄을 다 남겨
|
두 대상액이 **다른 구간에 떨어질 수 있어** A 가 항상 작지는 않다. 두 줄을 다 남겨
|
||||||
화면이 나란히 보이게 한다(실무 `안전관리비검토` 시트와 같은 서식).
|
화면이 나란히 보이게 한다(실무 `안전관리비검토` 시트와 같은 서식).
|
||||||
"""
|
"""
|
||||||
|
from B09_Estimation.B09_Estimation_Engine_Cost import floor_won
|
||||||
|
|
||||||
variable = dataset.variable("rate_safety_pct")
|
variable = dataset.variable("rate_safety_pct")
|
||||||
brackets = variable["brackets"]
|
brackets = variable["brackets"]
|
||||||
|
|
||||||
|
# 제3조(적용범위) — 「총공사금액 2천만 원 이상인 공사에 적용」. 하한은 요율 데이터가 든다.
|
||||||
|
# ⚠ 견주는 값은 **규모 기준액**(설계자가 준 추정가격 · 없으면 수렴한 총원가)이다. 고시의
|
||||||
|
# 「총공사금액」과 딱 같은 말은 아니나(관급·부가세 자리가 다름) 계산 차례상 안전관리비
|
||||||
|
# 앞에 설 수 있는 값이 그것뿐이라 같은 축으로 쓴다 — 보건관리자 문턱도 같은 축이다.
|
||||||
|
if not _threshold_met(
|
||||||
|
dataset, "rate_safety_pct", "minimum_total_construction_amount_krw", ctx.scale_reference
|
||||||
|
):
|
||||||
|
return _ZERO # 대상 아님 — 줄 자체를 만들지 않는다(0 원으로 채우지 않음)
|
||||||
|
|
||||||
owner_supplied = data.owner_supplied_for_safety_krw
|
owner_supplied = data.owner_supplied_for_safety_krw
|
||||||
if owner_supplied is None:
|
if owner_supplied is None:
|
||||||
owner_supplied = data.owner_supplied_material_krw
|
owner_supplied = data.owner_supplied_material_krw
|
||||||
if data.owner_supplied_includes_vat:
|
if data.owner_supplied_includes_vat:
|
||||||
|
# 제4조① 단서는 「해당 재료비를 **대상액에 포함**」까지만 적고 부가세를 말하지 않는다.
|
||||||
|
# ÷1.1 은 **부가세 제외 환산**이며 근거는 실무다 — 실무 원가계산서 **6건이 모두**
|
||||||
|
# 「(직노+직재+간재+관급재/1.1) × 율」로 적었다(2026-09-14 골든셋 전수 확인).
|
||||||
owner_supplied = owner_supplied / _VAT_DIVISOR
|
owner_supplied = owner_supplied / _VAT_DIVISOR
|
||||||
|
|
||||||
base_with = ctx.material_cost + ctx.direct_labor_cost + owner_supplied
|
base_with = ctx.material_cost + ctx.direct_labor_cost + owner_supplied
|
||||||
@@ -248,7 +262,15 @@ def safety_management_cost(
|
|||||||
)
|
)
|
||||||
percent = rate_percent(row, label=label)
|
percent = rate_percent(row, label=label)
|
||||||
flat = base_amount(row)
|
flat = base_amount(row)
|
||||||
return (base * percent / _HUNDRED + flat) * multiplier, percent, flat
|
# ⭐ 2026-09-14 고침 — **호별 산정액을 먼저 원 단위로 맺고** 1.2 를 곱한다.
|
||||||
|
# 고시 제4조① 단서 「… 대상액에서 제외하고 **산출한 산업안전보건관리비**의 1.2배」 —
|
||||||
|
# 1.2배의 대상은 1·2호로 **산정이 끝난 금액**이다. 종전엔 1.2 를 곱한 뒤 한 번만 버려
|
||||||
|
# 영월 2024 B 줄이 20,330,639 로 원본(20,330,638)보다 1원 컸다(골든셋 실증).
|
||||||
|
# A(배수 1)는 어느 차례로 해도 같은 값이다.
|
||||||
|
# ⚠ 안 고른 갈래 — 거창 2025 원본은 `버림(밑수 × 율 × 1.2)` 로 1원 위다. 그 서류는
|
||||||
|
# 시트 이름·줄 차례가 달라 **STmate 출력이 아니며**, 우리 기준은 STmate 재현이라
|
||||||
|
# 사유로만 남기고 채택하지 않는다(브레인 판정 2026-09-14).
|
||||||
|
return floor_won(base * percent / _HUNDRED + flat) * multiplier, percent, flat
|
||||||
|
|
||||||
raw_a, percent_a, flat_a = evaluate(base_with, Decimal(1), "안전관리비 A(관급 포함)")
|
raw_a, percent_a, flat_a = evaluate(base_with, Decimal(1), "안전관리비 A(관급 포함)")
|
||||||
raw_b, percent_b, flat_b = evaluate(
|
raw_b, percent_b, flat_b = evaluate(
|
||||||
@@ -257,8 +279,6 @@ def safety_management_cost(
|
|||||||
|
|
||||||
# 어느 쪽이 채택인지 먼저 정해 두 줄에 표시를 단다 — 화면이 나란히 보이고
|
# 어느 쪽이 채택인지 먼저 정해 두 줄에 표시를 단다 — 화면이 나란히 보이고
|
||||||
# 채택 줄이 눈에 띄어야 한다(PLAN 8-12 실무 `안전관리비검토` 시트 서식).
|
# 채택 줄이 눈에 띄어야 한다(PLAN 8-12 실무 `안전관리비검토` 시트 서식).
|
||||||
from B09_Estimation.B09_Estimation_Engine_Cost import floor_won
|
|
||||||
|
|
||||||
adopted = "A" if floor_won(raw_a) <= floor_won(raw_b) else "B"
|
adopted = "A" if floor_won(raw_a) <= floor_won(raw_b) else "B"
|
||||||
|
|
||||||
amount_a = emit(
|
amount_a = emit(
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""B09 원가계산 — **표토제거 답외구간** 9-15-2 (2026-09-14 브레인 ㉰ 첫째).
|
||||||
|
|
||||||
|
B08 준비공 「표토제거」 줄이 부르는 코드인데 표가 계수표(T·L·E·q0·e·f·V1·V2·t)라 일위대가가 안 섰음.
|
||||||
|
|
||||||
|
q = q0 × e · ㎝ = L/V1 + L/V2 + t · Q1 = 60 × q × f × E / ㎝ (㎥/hr) · Q = Q1 / T (㎡/hr)
|
||||||
|
[주]① 무한궤도 불도저(19ton) · ③ 건설품셈 8-2-1 불도저 참조 → 불도저 식(`dozer_hourly_output`) 그대로 + T 로 나눔
|
||||||
|
기종은 표의 q0·V1·V2(1단)로 8-2-1 표에서 되짚음(`resolve_dozer`) — [주]① 19ton 과 맞는지 시험이 봄
|
||||||
|
|
||||||
|
⚠ 실무 영월 호표 Q=576.95 는 E 자리에 e(0.96)를 넣은 값 — 원문이 또렷해 원문 E 로 셈(까닭은 `KnownGaps`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
CODE = "FP-09-15-02"
|
||||||
|
TABLE = "F0282"
|
||||||
|
_RE_SYMBOL = re.compile(r"^([A-Za-z]\d?)\s*\(")
|
||||||
|
_RE_GEAR = re.compile(r"(\d+)\s*단")
|
||||||
|
|
||||||
|
|
||||||
|
def _factors(node: dict[str, Any]):
|
||||||
|
"""(불도저 계수, T) — 칸이 모자라거나 기종이 안 좁혀지면 까닭 글."""
|
||||||
|
from B09_Estimation.B09_Estimation_MachineProductivity import parse_measure
|
||||||
|
from B09_Estimation.B09_Estimation_MachineProductivity_Dozer import DozerFactors, resolve_dozer
|
||||||
|
|
||||||
|
table = next((t for t in node.get("tables") or [] if t.get("pum_table_id") == TABLE), {})
|
||||||
|
values: dict[str, Decimal] = {}
|
||||||
|
gear = 1
|
||||||
|
for row in table.get("raw_row") or []:
|
||||||
|
found = _RE_SYMBOL.match(str(row[0]).strip()) if row else None
|
||||||
|
value = parse_measure(str(row[1])) if found and len(row) > 1 else None
|
||||||
|
if value is not None:
|
||||||
|
values[found.group(1)] = value
|
||||||
|
shift = _RE_GEAR.search(str(row[1]))
|
||||||
|
gear = int(shift.group(1)) if shift else gear
|
||||||
|
missing = [k for k in ("T", "L", "E", "q0", "e", "f", "V1", "V2") if k not in values]
|
||||||
|
if missing:
|
||||||
|
return f"9-15-2 표 칸 없음: {', '.join(missing)}"
|
||||||
|
machine = resolve_dozer(values["q0"], values["V1"], values["V2"], gear)
|
||||||
|
if machine is None:
|
||||||
|
return f"삽날 {values['q0']}㎥ · {values['V1']}/{values['V2']}m/분({gear}단) 으로 불도저가 안 좁혀짐"
|
||||||
|
factors = DozerFactors(
|
||||||
|
work_item_code=CODE,
|
||||||
|
blade_capacity_m3=values["q0"],
|
||||||
|
distance_factor=values["e"],
|
||||||
|
volume_factor=values["f"],
|
||||||
|
efficiency=values["E"],
|
||||||
|
haul_distance_m=values["L"],
|
||||||
|
forward_speed_m_min=values["V1"],
|
||||||
|
reverse_speed_m_min=values["V2"],
|
||||||
|
machine_code=machine[0],
|
||||||
|
machine_name=machine[1],
|
||||||
|
)
|
||||||
|
return factors, values["T"]
|
||||||
|
|
||||||
|
|
||||||
|
def topsoil_output(node: dict[str, Any] | None = None) -> tuple[Decimal, Decimal]:
|
||||||
|
"""(Q1 ㎥/hr, Q ㎡/hr) — 둘 다 소수 2자리로 확정한 뒤 씀(명세 7장)."""
|
||||||
|
from B09_Estimation.B09_Estimation_MachineProductivity import fix2
|
||||||
|
from B09_Estimation.B09_Estimation_MachineProductivity_Dozer import dozer_hourly_output
|
||||||
|
from B09_Estimation.B09_Estimation_ResourceAxis import load_work_item_master
|
||||||
|
|
||||||
|
if node is None:
|
||||||
|
node = next(n for n in load_work_item_master()["work_items"] if n["work_item_code"] == CODE)
|
||||||
|
found = _factors(node)
|
||||||
|
if isinstance(found, str):
|
||||||
|
raise ValueError(found)
|
||||||
|
factors, thickness = found
|
||||||
|
q1 = dozer_hourly_output(factors)
|
||||||
|
return q1, fix2(q1 / thickness)
|
||||||
|
|
||||||
|
|
||||||
|
def attach_topsoil_removal(build: Any, nodes_by_code: dict[str, dict[str, Any]]) -> None:
|
||||||
|
"""`B-FP-09-15-02` — 불도저 1/Q hr/㎡ 한 줄(D). 기계 층이 없거나 표가 달라지면 까닭만."""
|
||||||
|
from B09_Estimation.B09_Estimation_PriceBook import PriceKind, PriceTitle
|
||||||
|
|
||||||
|
node = nodes_by_code.get(CODE)
|
||||||
|
title_code = f"B-{CODE}"
|
||||||
|
if node is None or title_code in build.book.titles:
|
||||||
|
return
|
||||||
|
found = _factors(node)
|
||||||
|
if isinstance(found, str):
|
||||||
|
build.component_gaps[CODE] = found
|
||||||
|
return
|
||||||
|
factors, thickness = found
|
||||||
|
hourly = f"X-{factors.machine_code}"
|
||||||
|
if hourly not in build.book.titles:
|
||||||
|
build.component_gaps[CODE] = f"{factors.machine_name} 시간당 사용료가 안 섬"
|
||||||
|
return
|
||||||
|
q1, q = topsoil_output(node)
|
||||||
|
build.book.add_title(
|
||||||
|
PriceTitle(
|
||||||
|
code=title_code,
|
||||||
|
kind=PriceKind.UNIT_PRICE,
|
||||||
|
name=str(node.get("name") or CODE),
|
||||||
|
spec="표토제거",
|
||||||
|
unit="㎡",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
build.book.add_output_detail(
|
||||||
|
title_code,
|
||||||
|
hourly,
|
||||||
|
Decimal(1) / q,
|
||||||
|
f"{factors.formula_text} → Q = Q1 {q1} ÷ T {thickness}m = {q} ㎡/hr"
|
||||||
|
" (산림품셈 9-15-2 [주]②③ · 건설 8-2-1)",
|
||||||
|
output=q,
|
||||||
|
)
|
||||||
|
if CODE in build.skipped:
|
||||||
|
build.skipped.remove(CODE)
|
||||||
@@ -24,8 +24,10 @@
|
|||||||
|
|
||||||
⚠ **거리는 설계 입력이다** — 안 넣으면 **줄이 안 선다**(사토장 운반거리와 같은 자리).
|
⚠ **거리는 설계 입력이다** — 안 넣으면 **줄이 안 선다**(사토장 운반거리와 같은 자리).
|
||||||
임의 거리를 넣으면 금액이 조용히 서므로 **비면 사유만 남긴다.**
|
임의 거리를 넣으면 금액이 조용히 서므로 **비면 사유만 남긴다.**
|
||||||
⚠ **회수(몇 대를 몇 번 나르나)는 여기서 안 정한다** — 단가는 「회당」이고, 회수는 수량 쪽
|
⚠ **회수(몇 대를 몇 번 나르나)도 설계 입력이다** (2026-09-14 브레인 판정으로 닫음).
|
||||||
(설계 입력)이다. 품셈이 대수·회수를 정해 주지 않는다.
|
원문 전수 확인 — 산림품셈 10-4 · 건설품셈 8-1-3 은 **회당 단가 산출식만** 주고
|
||||||
|
대수·왕복 횟수를 정하는 공식이 **없다**. 그래서 `transport_trips` 칸을 두고,
|
||||||
|
비면 금액을 안 세우고 사유만 남긴다. **회수 × 회당 단가** 곱하기 하나뿐이다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -86,6 +88,12 @@ ASSUMPTION_TEXT = (
|
|||||||
"⚠ 원문이 안 정해 우리가 정한 둘 — ㉠ 운반시간의 속도는 8-1-6의 2 나 이동속도표에서 가져옴"
|
"⚠ 원문이 안 정해 우리가 정한 둘 — ㉠ 운반시간의 속도는 8-1-6의 2 나 이동속도표에서 가져옴"
|
||||||
"(그 표는 자주식 이동표라 쓰임이 꼭 같지는 않음) · ㉡ 운반시간을 왕복으로 봄."
|
"(그 표는 자주식 이동표라 쓰임이 꼭 같지는 않음) · ㉡ 운반시간을 왕복으로 봄."
|
||||||
)
|
)
|
||||||
|
#: 회수 칸 사유 — 화면이 「왜 설계자가 넣나」를 읽는 자리.
|
||||||
|
TRIPS_NOTE = (
|
||||||
|
"회수(대수 × 왕복)는 **설계 입력**입니다 — 산림품셈 10-4 · 건설품셈 8-1-3 은 **회당 단가"
|
||||||
|
" 산출식만** 주고 대수·횟수를 정하는 공식이 원문에 없습니다(2026-09-14 전수 확인)."
|
||||||
|
" 비워 두면 수송비 금액이 서지 않습니다."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def parse_distance_km(raw: Any) -> Decimal | None:
|
def parse_distance_km(raw: Any) -> Decimal | None:
|
||||||
@@ -102,6 +110,26 @@ def parse_distance_km(raw: Any) -> Decimal | None:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def parse_trips(raw: Any) -> Decimal | None:
|
||||||
|
"""설정 칸의 **회수**(대수 × 왕복). 비거나 0 이면 `None` — 금액이 안 선다.
|
||||||
|
|
||||||
|
⚠ 품셈이 안 정하는 값이라 **우리가 기본값을 두지 않는다**(1 회로 때우지 않음).
|
||||||
|
"""
|
||||||
|
text = str(raw or "").strip().rstrip("회").strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
value = Decimal(text)
|
||||||
|
except (ArithmeticError, ValueError):
|
||||||
|
raise ValueError(f"수송 회수를 숫자로 못 읽었습니다: {raw!r}") from None
|
||||||
|
return value if value > 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def transport_amount(unit_price_krw: Decimal, trips: Decimal | None) -> Decimal | None:
|
||||||
|
"""수송비 = 회당 단가 × 회수. 회수가 없으면 `None`(0 원으로 안 채운다)."""
|
||||||
|
return None if trips is None else unit_price_krw * trips
|
||||||
|
|
||||||
|
|
||||||
def road_class(key: str | None) -> dict[str, Any] | None:
|
def road_class(key: str | None) -> dict[str, Any] | None:
|
||||||
"""도로 구분. 못 고르면 `None` — **기본 도로를 우리가 정하지 않는다.**"""
|
"""도로 구분. 못 고르면 `None` — **기본 도로를 우리가 정하지 않는다.**"""
|
||||||
return _ROAD_BY_KEY.get(str(key or ""))
|
return _ROAD_BY_KEY.get(str(key or ""))
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ function drawDetail(
|
|||||||
}
|
}
|
||||||
if (detail.unattached_note) box.append(hint(detail.unattached_note.replace(/\*\*/g, ""), true));
|
if (detail.unattached_note) box.append(hint(detail.unattached_note.replace(/\*\*/g, ""), true));
|
||||||
if (detail.known_gap_note) box.append(hint(detail.known_gap_note, true));
|
if (detail.known_gap_note) box.append(hint(detail.known_gap_note, true));
|
||||||
|
if (detail.form_basis_note) box.append(hint(detail.form_basis_note, true));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 본표 한 장을 `box` 에 — 자취를 쌓고 서버 본표를 받아 그림. */
|
/** 본표 한 장을 `box` 에 — 자취를 쌓고 서버 본표를 받아 그림. */
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user