Merge remote-tracking branch 'origin/main_desktop_1' into sub_desktop_1
This commit is contained in:
@@ -761,6 +761,15 @@
|
||||
"required": true,
|
||||
"phase": "detail"
|
||||
},
|
||||
{
|
||||
"key": "stone_kind",
|
||||
"label": "돌 종류",
|
||||
"input": "select",
|
||||
"choices": ["야면석·호박돌", "깬잡석", "깬돌", "견치돌"],
|
||||
"default": null,
|
||||
"required": true,
|
||||
"phase": "detail"
|
||||
},
|
||||
{
|
||||
"key": "side",
|
||||
"label": "설치 측",
|
||||
@@ -826,6 +835,15 @@
|
||||
"required": true,
|
||||
"phase": "detail"
|
||||
},
|
||||
{
|
||||
"key": "stone_kind",
|
||||
"label": "돌 종류",
|
||||
"input": "select",
|
||||
"choices": ["야면석·호박돌", "깬잡석", "깬돌", "견치돌"],
|
||||
"default": null,
|
||||
"required": true,
|
||||
"phase": "detail"
|
||||
},
|
||||
{
|
||||
"key": "side",
|
||||
"label": "설치 측",
|
||||
|
||||
@@ -218,6 +218,9 @@ export interface CulvertSideSpec {
|
||||
/** 배수관 측점의 세트(배관·기슭막이·보호공) 제원 — 횡단 카드 오버레이 입력. */
|
||||
export interface CulvertSet {
|
||||
type: "pipe";
|
||||
/** 이 시설이 **놓인** 누가거리(m) — 세트는 옆 측점에도 붙으므로 소유 측점을 가리는 열쇠다.
|
||||
* (2026-09-08 — 없을 때 관 길이가 이웃 측점에도 실려 같은 관을 두 번 셀 뻔했다.) */
|
||||
chainage_m?: number;
|
||||
pipe_kind: string | null;
|
||||
diameter_m: number;
|
||||
/** 관 위 최소 토피(m) — 별표2 교량·암거 복토 50㎝ 교차 참조. B05 하향 차단 기준. */
|
||||
@@ -467,6 +470,8 @@ export interface CrossDesign {
|
||||
/** 측점별 **암 절토 경사비**(1:n 의 n) — 사용자가 카드에 넣은 값(2026-09-07).
|
||||
* 0 은 「표준값을 씀」이다. 계산에 쓰이는 값이 아니라 **입력을 되싣는 자리**다. */
|
||||
cut_slope_ratio_user?: number;
|
||||
/** 배수관 연장(m) — 기하가 **m 단위 올림까지** 끝낸 값(2026-09-08). 수량(B08)이 읽는다. */
|
||||
pipe_length_m?: number;
|
||||
}
|
||||
|
||||
export interface CrossDesignResponse {
|
||||
@@ -504,6 +509,8 @@ export interface CrossSectionPatch {
|
||||
display_half_width_m?: number;
|
||||
/** 측점별 암 절토 경사비(2026-09-07). 0 = 표준값으로 되돌림. */
|
||||
cut_slope_ratio_user?: number;
|
||||
/** 배수관 연장(m) — 구조물 면적과 같은 길로 정본에 실린다(2026-09-08). */
|
||||
pipe_length_m?: number;
|
||||
inlet_structure?: "auto" | "revet" | "I" | "L" | "U";
|
||||
basin_adjust?: {
|
||||
innerWidthM: number;
|
||||
|
||||
@@ -435,7 +435,13 @@ def attach_culvert_sets(project_root: Path, cross_sections: list[dict[str, Any]]
|
||||
if abs(chainage - pipe_chainage) <= reach:
|
||||
# 세월교·BOX암거·물넘이는 배수관과 그림이 달라 키를 나눈다 — 소비처가 섞이지 않는다.
|
||||
kind = spec.get("type")
|
||||
section[_SECTION_KEYS.get(str(kind), "culvert")] = spec
|
||||
# ⚠ 스펙에 **그 시설이 놓인 누가거리**를 함께 얹는다(2026-09-08). 세트는 폭의
|
||||
# 절반까지 옆 측점에도 붙으므로, 이것이 없으면 소비처가 「소유 측점」을 못 가려
|
||||
# **같은 시설을 여러 측점에서 센다**(관 9개에 길이가 10곳 실렸던 자리).
|
||||
section[_SECTION_KEYS.get(str(kind), "culvert")] = {
|
||||
**spec,
|
||||
"chainage_m": pipe_chainage,
|
||||
}
|
||||
attached += 1
|
||||
break
|
||||
return attached
|
||||
|
||||
@@ -175,6 +175,10 @@ class CrossSectionPatch(BaseModel):
|
||||
fill_area_m2: float | None = Field(default=None, ge=0)
|
||||
cut_soil_area_m2: float | None = Field(default=None, ge=0)
|
||||
cut_rock_area_m2: float | None = Field(default=None, ge=0)
|
||||
# 배수관 연장(m) — 브라우저 기하가 **m 단위 올림까지** 끝낸 값. 수량(B08)이 배수관
|
||||
# 공종을 세려면 정본에 있어야 한다(2026-09-08). 계산은 서버가 같은 코드를 Node 로
|
||||
# 돌려 내므로 한 벌이다(`B06_Section_Server_Calc_Node`).
|
||||
pipe_length_m: float | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class SectionConfirmRequest(BaseModel):
|
||||
|
||||
@@ -52,7 +52,15 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
BUNDLE = ROOT / "config" / "server_calc_node" / "B06_Section_Server_Calc_Node.js"
|
||||
_NPM_SCRIPT = "build:server-calc"
|
||||
# 정본에 얹는 값만 받는다 — Node 가 다른 키를 내도 설계 데이터에 흘리지 않는다.
|
||||
_AREA_KEYS = ("cut_area_m2", "fill_area_m2", "cut_soil_area_m2", "cut_rock_area_m2")
|
||||
# ⚠ **TS 쪽 `STRUCTURE_ROW_KEYS` 와 짝이다.** 한쪽만 늘리면 Node 가 값을 내도 여기서 조용히
|
||||
# 버려진다(2026-09-08 관 길이를 더하며 실제로 걸린 자리). 시험이 두 목록을 대조한다.
|
||||
_AREA_KEYS = (
|
||||
"cut_area_m2",
|
||||
"fill_area_m2",
|
||||
"cut_soil_area_m2",
|
||||
"cut_rock_area_m2",
|
||||
"pipe_length_m",
|
||||
)
|
||||
|
||||
|
||||
def _mass_haul_context() -> dict[str, Any]:
|
||||
|
||||
@@ -107,12 +107,20 @@ export function trimOfLayouts(layouts: StoredLayouts) {
|
||||
);
|
||||
}
|
||||
|
||||
/** 정본에 얹는 면적 키 — 이 넷만 오간다. */
|
||||
export const STRUCTURE_AREA_KEYS = [
|
||||
/**
|
||||
* 정본에 얹는 키 — 구조물이 선 측점에서만 나오는 값들.
|
||||
*
|
||||
* 면적 넷에 **관 길이**를 더했다(2026-09-08). 관 길이는 브라우저 기하가 **m 단위 올림까지**
|
||||
* 끝낸 값인데 정본에 없어 **수량이 배수관 연장을 못 냈다**(B08 창 보고). 계산을 서버로
|
||||
* 옮기거나 새로 짜지 않고, **이미 서버가 Node 로 돌리는 이 다리에 한 줄 더 실었다**
|
||||
* (CLAUDE.md 5장 — 계산은 한 벌).
|
||||
*/
|
||||
export const STRUCTURE_ROW_KEYS = [
|
||||
"cut_area_m2",
|
||||
"fill_area_m2",
|
||||
"cut_soil_area_m2",
|
||||
"cut_rock_area_m2",
|
||||
"pipe_length_m",
|
||||
] as const;
|
||||
|
||||
/** 측점 하나의 폐회로 면적 — 구조물이 없으면 null(표준 계산값이 이미 맞다). */
|
||||
@@ -122,9 +130,24 @@ function areaRowOf(
|
||||
): Record<string, number> | null {
|
||||
const layouts = computeStoredLayouts(section, sections);
|
||||
if (!layouts) return null;
|
||||
// 관 길이는 면적과 **따로** 낸다 — 폐회로 면적을 못 내는 측점(설계선이 모자란 자리)에도
|
||||
// 관은 서 있고, 수량은 그 길이를 필요로 한다(2026-09-08).
|
||||
//
|
||||
// ⚠ **관을 가진 측점(소유)에만 싣는다.** 옆 측점도 그 관 구간에 걸리면 레이아웃을 만들지만
|
||||
// (`culvertLinkFor` — 3D·카드가 이어 그리려고), 그 자리에 길이를 실으면 **같은 관을 두 번**
|
||||
// 세게 된다. 실측에서 관 9개에 값이 10곳 실렸던 자리다.
|
||||
const pipeOwner =
|
||||
!!section.culvert &&
|
||||
(typeof section.culvert.chainage_m !== "number" ||
|
||||
Math.abs(section.culvert.chainage_m - section.chainage_m) <= CHAINAGE_TOLERANCE_M);
|
||||
const pipeLengthM = pipeOwner ? layouts.culvert?.pipe?.lengthM : undefined;
|
||||
const pipeRow: Record<string, number> | null =
|
||||
typeof pipeLengthM === "number" && pipeLengthM > 0
|
||||
? { chainage_m: section.chainage_m, pipe_length_m: Number(pipeLengthM.toFixed(4)) }
|
||||
: null;
|
||||
const trim = trimOfLayouts(layouts);
|
||||
const design = layouts.design;
|
||||
if (!trim || !Array.isArray(design.design_line) || design.design_line.length < 2) return null;
|
||||
if (!trim || !Array.isArray(design.design_line) || design.design_line.length < 2) return pipeRow;
|
||||
const ground = section.samples
|
||||
.filter((sample) => sample.valid !== false && typeof sample.elevation_m === "number")
|
||||
.map((sample) => ({ offset: sample.offset_m ?? 0, elevation: sample.elevation_m as number }))
|
||||
@@ -136,9 +159,10 @@ function areaRowOf(
|
||||
rockBoundaryOffsetM:
|
||||
typeof design.rock_boundary_offset_m === "number" ? design.rock_boundary_offset_m : null,
|
||||
});
|
||||
if (!areas) return null;
|
||||
if (!areas) return pipeRow;
|
||||
const round = (value: number): number => Number(value.toFixed(4));
|
||||
const row: Record<string, number> = {
|
||||
...(pipeRow ?? {}),
|
||||
chainage_m: section.chainage_m,
|
||||
cut_area_m2: round(areas.cutAreaM2),
|
||||
fill_area_m2: round(areas.fillAreaM2),
|
||||
@@ -173,7 +197,7 @@ export function applyStructureAreaRows(
|
||||
const design = sections.find((item) => item.chainage_m === row.chainage_m)?.design as
|
||||
Record<string, unknown> | undefined;
|
||||
if (!design) continue;
|
||||
for (const key of STRUCTURE_AREA_KEYS) {
|
||||
for (const key of STRUCTURE_ROW_KEYS) {
|
||||
if (typeof row[key] === "number") design[key] = row[key];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
} from "@util/common_util_cross_berm";
|
||||
import {
|
||||
applyStructureAreaRows,
|
||||
STRUCTURE_AREA_KEYS,
|
||||
STRUCTURE_ROW_KEYS,
|
||||
structureAreaRows,
|
||||
} from "./B06_Section_Structure_Layouts";
|
||||
import { crossDesignChoices } from "./B06_Section_Cross_Design_Session";
|
||||
@@ -393,7 +393,7 @@ export function collectSectionEdits(ctx: SectionPersistContext): {
|
||||
const design = section.design as Record<string, unknown> | undefined;
|
||||
if (!design) continue;
|
||||
let patch: CrossSectionPatch | null = null;
|
||||
for (const key of STRUCTURE_AREA_KEYS) {
|
||||
for (const key of STRUCTURE_ROW_KEYS) {
|
||||
if (typeof design[key] !== "number") continue;
|
||||
patch = patch ?? patchFor(section.chainage_m);
|
||||
patch[key] = design[key] as number;
|
||||
|
||||
@@ -158,6 +158,7 @@ ORIGIN_STRUCTURE = "structure"
|
||||
ORIGIN_SLOPE = "slope"
|
||||
ORIGIN_HAUL = "haul"
|
||||
ORIGIN_PREPARATION = "preparation"
|
||||
ORIGIN_PIPE = "pipe"
|
||||
|
||||
#: 암 시공법 → 매핑표의 지반 이름. 품셈이 **긁어내기와 터뜨리기를 다른 공종**으로 두기 때문에
|
||||
#: 갈래 이름(연암·보통암…)만으로는 공종을 못 고른다(2026-09-07 일감 9 실서버에서 드러남).
|
||||
@@ -202,6 +203,8 @@ class WorkItemMapping:
|
||||
composite: dict[str, Any] = field(default_factory=dict)
|
||||
concrete_placing: dict[str, Any] = field(default_factory=dict)
|
||||
unit_conversion: dict[str, Any] = field(default_factory=dict)
|
||||
#: 배수관 — 관종별 공종·연장 키. 관 정본은 `pipe_points.json` 이다.
|
||||
pipe: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def for_earthwork(self, group: str, ground: str | None) -> dict[str, Any] | None: # noqa: D401
|
||||
"""공종+지반유형으로 찾는다. 지반이 갈리지 않는 공종은 `ground` 없는 줄이 받는다."""
|
||||
@@ -253,6 +256,7 @@ def load_mapping(path: Path | None = None) -> WorkItemMapping:
|
||||
pending_user=payload.get("pending_user") or {},
|
||||
composite=payload.get("composite") or {},
|
||||
concrete_placing=payload.get("concrete_placing") or {},
|
||||
pipe=payload.get("pipe") or {},
|
||||
unit_conversion=(payload.get("composite") or {}).get("unit_conversion") or {},
|
||||
)
|
||||
|
||||
@@ -579,13 +583,23 @@ def _haul_rows(
|
||||
return rows, unmatched
|
||||
|
||||
|
||||
def blocked_of(structure: dict[str, Any], class_basis: str = "") -> tuple[str | None, str]:
|
||||
def blocked_of(
|
||||
structure: dict[str, Any], class_basis: str = "", has_code: bool = False
|
||||
) -> tuple[str | None, str]:
|
||||
"""(막힌 갈래, 사유). 안 막혔으면 `(None, "")`.
|
||||
|
||||
⚠ 사유 문구는 **`B08_Quantity_Wording` 것을 그대로** 쓴다 — 두 벌로 짜면 갈린다.
|
||||
전개 알림(`notes`)에 이미 사람 말로 적혀 있으므로 그것을 그대로 옮긴다.
|
||||
|
||||
⚠⚠ **전개식이 없다고 다 막힌 것이 아니다** (2026-09-08 V-3 에서 드러남).
|
||||
B군 종단배수(산마루측구 12-9-2 · 소단측구 12-9-3 · 맹암거 12-10)는 품셈 밑수가
|
||||
**1 m** 라 **연장이 곧 수량**이다 — 원단위 전개가 필요 없다. 그런데 「성분이 없으면
|
||||
전개식 없음」으로 단정해 B09 가 **「우리가 만들 것」으로 빼 금액이 0** 이었다.
|
||||
**공종코드가 붙었고 수량이 있으면 막힌 것이 아니다.**
|
||||
"""
|
||||
notes = [str(note) for note in structure.get("notes") or []]
|
||||
if has_code and float(structure.get("length_m") or 0.0) > 0:
|
||||
return None, ""
|
||||
if structure.get("components"):
|
||||
# 물량은 섰는데 **단가 갈래**를 못 고른 자리(돌쌓기 뒷길이 등).
|
||||
if class_basis and "입력되지 않았습니다" in class_basis:
|
||||
@@ -643,7 +657,7 @@ def _structure_rows(
|
||||
parts_missing: list[dict[str, Any]] = []
|
||||
if composite:
|
||||
parts, parts_missing = composite_quantities(structure, composite, mapping)
|
||||
blocked_kind, blocked_reason = blocked_of(structure, class_basis)
|
||||
blocked_kind, blocked_reason = blocked_of(structure, class_basis, has_code=bool(code))
|
||||
# 갈래 축과 **저장 제원 원본값**. 가공하지 않는다.
|
||||
variant_axis = str(entry.get("variant_axis") or "") or None
|
||||
variant_value = (structure.get("options") or {}).get(variant_axis) if variant_axis else None
|
||||
@@ -859,6 +873,130 @@ def _prep_blocked_kind(status: str) -> str | None:
|
||||
return BLOCKED_UNIT_DATA_MISSING
|
||||
|
||||
|
||||
def _pipe_rows(pipe_table: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""배수관 줄 — 값이 서는 줄도, 못 서는 줄도 함께 보낸다(준비공과 같은 규칙).
|
||||
|
||||
⚠ 터파기·되메우기를 붙이지 않는다 — 관 부설과 굴착이 각각 오면 **같은 굴착을 두 번** 센다
|
||||
(B09 ㉡ 가드와 같은 자리).
|
||||
"""
|
||||
rows: list[dict[str, Any]] = []
|
||||
for row in pipe_table.get("rows") or []:
|
||||
ready = bool(row.get("in_bill"))
|
||||
rows.append(
|
||||
{
|
||||
"work_item_code": row.get("work_item_code"),
|
||||
"name": f"배수관({row.get('kind')})",
|
||||
"spec": f"Ø{row.get('variant_value')}" if row.get("variant_value") else "",
|
||||
"unit": str(row.get("unit") or "m"),
|
||||
"quantity": float(row.get("quantity") or 0.0),
|
||||
"quantity_gross": None,
|
||||
"application_ratio_pct": None,
|
||||
"application_ratio_breakdown": None,
|
||||
"quantity_breakdown": None,
|
||||
"ground_class": None,
|
||||
"haul_distance_m": None,
|
||||
"haul_equipment": None,
|
||||
"station_from": row.get("chainage_m"),
|
||||
"station_to": row.get("chainage_m"),
|
||||
"excavation_method": None,
|
||||
"spec_detail": f"Ø{row.get('variant_value')}" if row.get("variant_value") else "",
|
||||
"composite_parts": None,
|
||||
"structure_kind": None,
|
||||
"blocked_kind": row.get("blocked_kind"),
|
||||
"blocked_reason": str(row.get("blocked_reason") or ""),
|
||||
# 갈래는 **저장 원본값**만 — 「…㎜ 이하」 구간 나누기는 원문을 읽는 쪽 몫.
|
||||
"variant_axis": row.get("variant_axis"),
|
||||
"variant_value": row.get("variant_value"),
|
||||
"spec_class": None,
|
||||
"spec_class_basis": str(row.get("blocked_reason") or ""),
|
||||
"composite_not_ready": None,
|
||||
"in_bill": ready,
|
||||
"in_bill_reason": "" if ready else str(row.get("blocked_reason") or ""),
|
||||
"origin": ORIGIN_PIPE,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _length_rows(
|
||||
length_table: list[dict[str, Any]], mapping: WorkItemMapping
|
||||
) -> list[dict[str, Any]]:
|
||||
"""B군 종단배수 — **종류별 한 줄**로 낸다(연장이 곧 수량).
|
||||
|
||||
⚠ **왜 구조물별로 안 내나** — `common_util_structure_lengths` 가 **겹친 구간을 합쳐**
|
||||
준다. 같은 시설을 겹쳐 놓으면 구조물별로 세는 순간 그 구간을 **두 번** 센다.
|
||||
그 규칙(겹침 합치기 · 측구 제외 · 관 소관 제외)이 이미 그 함수에 있으므로
|
||||
**두 벌로 짜지 않는다**(2026-09-08 랩탑 창 제안, 두 창 합의).
|
||||
|
||||
⚠ **C군(돌쌓기·옹벽 등)은 여기로 오지 않는다** — 그 함수는 종류별로 뭉쳐 내는데,
|
||||
C군은 **측점·규격이 줄마다 달라** 구조물별로 서야 하고 자재도 줄마다 나온다.
|
||||
실무 내역도 B군은 「산마루측구 40m」 한 줄, C군은 구조물별 줄이다.
|
||||
|
||||
⚠ 겹침이 있으면(`length_m != raw_length_m`) **숨기지 않고 비고에 적는다.**
|
||||
"""
|
||||
rows: list[dict[str, Any]] = []
|
||||
for entry in length_table or []:
|
||||
type_id = str(entry.get("type_id") or "")
|
||||
found = mapping.for_structure(type_id)
|
||||
code = (found or {}).get("work_item_code")
|
||||
length = float(entry.get("length_m") or 0.0)
|
||||
raw = float(entry.get("raw_length_m") or length)
|
||||
# 구간 목록 — **겹침을 지운 뒤**의 것이라 그 합이 곧 `length_m` 이다
|
||||
# (80~120 과 100~140 은 80~140 한 줄로 합쳐져 온다, 2026-09-08 랩탑 창).
|
||||
# ⚠ 표기(`NO.4+0.0`)는 만들지 않는다 — 측점 간격을 아는 화면 몫이다.
|
||||
spans = [
|
||||
span
|
||||
for span in (entry.get("spans") or [])
|
||||
if span.get("start_m") is not None and span.get("end_m") is not None
|
||||
]
|
||||
span_note = " · ".join(f"{s['start_m']:g}~{s['end_m']:g}m" for s in spans)
|
||||
note = f"구간 {span_note}" if span_note else ""
|
||||
if abs(raw - length) > 1e-9:
|
||||
겹침 = f"입력 구간 합 {raw:g}m 에서 겹친 {raw - length:g}m 를 뺀 값"
|
||||
note = f"{note} · {겹침}" if note else 겹침
|
||||
# ⚠ 겹침 설명은 **비고**이지 막힌 사유가 아니다 — `blocked_reason` 에 넣으면
|
||||
# 받는 쪽이 「막힌 줄」로 읽어 금액을 안 붙인다(2026-09-08 실측에서 그랬다).
|
||||
reason = ""
|
||||
if code is None:
|
||||
reason = f"{entry.get('name') or type_id} — 품셈 공종을 아직 못 이었습니다"
|
||||
elif length <= 0:
|
||||
reason = f"{entry.get('name') or type_id} — 연장이 0 이라 값이 서지 않습니다"
|
||||
rows.append(
|
||||
{
|
||||
"work_item_code": code,
|
||||
"name": str(entry.get("name") or type_id),
|
||||
"spec": f"{entry.get('count')}개소",
|
||||
"unit": "m",
|
||||
"quantity": length,
|
||||
"quantity_gross": raw if note else None,
|
||||
"application_ratio_pct": None,
|
||||
"application_ratio_breakdown": None,
|
||||
"quantity_breakdown": None,
|
||||
"ground_class": None,
|
||||
"haul_distance_m": None,
|
||||
"haul_equipment": None,
|
||||
# 여러 구간이면 **처음과 끝**만 싣는다 — 사이 구간은 비고에 다 적혀 있다.
|
||||
"station_from": spans[0]["start_m"] if spans else None,
|
||||
"station_to": spans[-1]["end_m"] if spans else None,
|
||||
"excavation_method": None,
|
||||
"spec_detail": f"{entry.get('count')}개소",
|
||||
"composite_parts": None,
|
||||
"structure_kind": None,
|
||||
"blocked_kind": None if (code and length > 0) else BLOCKED_FORMULA_MISSING,
|
||||
"blocked_reason": reason,
|
||||
"variant_axis": None,
|
||||
"variant_value": None,
|
||||
"spec_class": None,
|
||||
"spec_class_basis": note,
|
||||
"composite_not_ready": None,
|
||||
"in_bill": bool(code and length > 0),
|
||||
"in_bill_reason": "" if (code and length > 0) else reason,
|
||||
"origin": ORIGIN_STRUCTURE,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _material_rows(material_table: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""자재 줄 — **공종코드를 붙이지 않는다.** 자재 축은 B09 카탈로그가 잇는다(8-7)."""
|
||||
rows: list[dict[str, Any]] = []
|
||||
@@ -887,6 +1025,8 @@ def build_handoff(
|
||||
unit_quantity_table: dict[str, Any] | None = None,
|
||||
material_table: dict[str, Any] | None = None,
|
||||
preparation_table: dict[str, Any] | None = None,
|
||||
length_table: list[dict[str, Any]] | None = None,
|
||||
pipe_table: dict[str, Any] | None = None,
|
||||
mapping: WorkItemMapping | None = None,
|
||||
ground_class_set: str | None = None,
|
||||
ground_classes: list[str] | None = None,
|
||||
@@ -913,7 +1053,11 @@ def build_handoff(
|
||||
unmatched.extend(misses)
|
||||
|
||||
# 준비공·사방공 — **못 내는 줄도 사유와 함께** 보낸다(빼면 빠진 줄이 안 보인다).
|
||||
# B군 종단배수 — 겹침을 합친 연장으로 종류별 한 줄(위 `_length_rows` 주석).
|
||||
work_items.extend(_length_rows(length_table or [], table))
|
||||
work_items.extend(_preparation_rows(preparation_table or {}))
|
||||
# 배수관 — 정본 셋(관 지점·측점 연장·매핑)을 이은 결과. 못 서는 줄도 사유와 함께 감.
|
||||
work_items.extend(_pipe_rows(pipe_table or {}))
|
||||
|
||||
# 콘크리트 타설 — 품은 이 줄, 재료는 자재 쪽. 겹치지 않는다(위 `_placing_rows` 주석).
|
||||
placing_rows, placing_notes = _placing_rows(
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
"""배수관 물량 — **정본 셋을 잇기만 한다** (2026-09-08 두 창 합의).
|
||||
|
||||
값이 어디서 오나
|
||||
관 자체(있나·어디·관경·관종) → `pipe_points.json` (레지스트리 `pipe` 타입이
|
||||
`managed_by: pipe_points`)
|
||||
관 연장(m) → 측점 `design.pipe_length_m`
|
||||
(B06 횡단이 **서버 Node 로** 계산해 m 단위 올림까지
|
||||
끝낸 값을 정본에 남긴다 — 계산이 두 벌이 아니다)
|
||||
관종 → 공종코드 → `work_item_mapping` 의 `pipe.kind_codes`
|
||||
|
||||
⚠ **여기서 길이를 짓지 않는다.** 앞서 「도로폭 = 관 길이」처럼 잡을 뻔했는데 그것이 곧
|
||||
임의 수치다. 연장이 없는 관은 **줄을 세우되 막힌 사유와 함께** 보낸다.
|
||||
|
||||
⚠ **`facility` 가 `pipe` 인 점만 배관이다.**
|
||||
`pipe_points.json` 은 **계곡 통과 시설 전부의 정본**이라 BOX암거·물넘이·세월교·독립
|
||||
기슭막이가 같은 파일에 있다. `pipe_diameter_mm` 유무로 가르면 **관경 미지정 관을 놓친다**
|
||||
(실측: `5601e828` 11점 중 2점이 `facility: ford_bridge`).
|
||||
|
||||
⚠ **유출·유입부 기슭막이는 여기서 세지 않는다.**
|
||||
관 옵션(`outlet_revet_*`)이 정본이고 구조물 목록에서는 빠졌다(2026-08-28 이관).
|
||||
구조물 쪽으로 또 세면 이중계상이다.
|
||||
|
||||
⚠ **터파기·되메우기를 관 줄에 붙이지 않는다.**
|
||||
관 부설과 굴착이 각각 오면 **같은 굴착을 두 번** 센다(B09 ㉡ 가드와 같은 자리).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
#: 배관으로 보는 `facility` 값. 그 밖(BOX암거·물넘이·세월교·독립 기슭막이)은 관이 아니다.
|
||||
FACILITY_PIPE = "pipe"
|
||||
|
||||
#: 연장을 못 찾은 줄의 막힌 갈래 — 「입력하면 풀림」이 아니라 **앞 단계가 내야 하는 값**이다.
|
||||
BLOCKED_LENGTH_MISSING = "input_missing"
|
||||
|
||||
#: ⚠ **둘을 갈라 말한다** — 「[저장]을 누르면 풀리는 것」과 「눌러도 안 풀리는 것」은
|
||||
#: 사용자가 할 일이 다르다. 뒤엣것에 앞 문구를 쓰면 눌러 보고 안 되어 헤맨다
|
||||
#: (2026-09-08 실측: 관 9개 중 3개가 **횡단 행 자체가 없는** 자리였다).
|
||||
NOTE_LENGTH_MISSING = (
|
||||
"관 연장이 아직 정본에 없습니다 — 횡단설계에서 [저장]을 한 번 누르면 "
|
||||
"그 측점의 관 길이가 남고 값이 섭니다"
|
||||
)
|
||||
NOTE_SECTION_MISSING = (
|
||||
"그 측점의 횡단 자체가 없습니다 — 관은 놓였는데 횡단이 안 만들어진 자리라 "
|
||||
"[저장]으로는 안 풀립니다. 횡단설계에서 그 측점이 서야 합니다"
|
||||
)
|
||||
#: 관 자리에 횡단이 있는지 볼 때의 허용 오차. **아주 좁게** — 옆 측점을 「있다」로 세면
|
||||
#: 거짓 안내가 된다. 길이 찾기(0.5m)보다 좁은 것은 뜻이 다르기 때문이다.
|
||||
SECTION_MATCH_TOLERANCE_M = 0.05
|
||||
|
||||
NOTE_KIND_DEFAULT = "관종을 안 정해 기본값({kind})으로 섰습니다 — 정하면 공종이 갈립니다"
|
||||
NOTE_KIND_UNKNOWN = "「{kind}」은(는) 아는 관종이 아니라 공종을 못 골랐습니다"
|
||||
|
||||
|
||||
def _length_by_chainage(designs: list[dict[str, Any]], key: str) -> dict[float, float]:
|
||||
"""측점별 관 길이. **없는 측점은 담지 않는다** — 0 으로 채우면 「없음」과 구별이 안 된다."""
|
||||
found: dict[float, float] = {}
|
||||
for row in designs or []:
|
||||
design = row.get("design") if isinstance(row, dict) else None
|
||||
if not isinstance(design, dict):
|
||||
continue
|
||||
value = design.get(key)
|
||||
if value is None:
|
||||
continue
|
||||
try:
|
||||
length = float(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if length > 0:
|
||||
found[round(float(row.get("chainage_m") or 0.0), 3)] = length
|
||||
return found
|
||||
|
||||
|
||||
def _nearest(lengths: dict[float, float], chainage: float, tolerance: float = 0.5) -> float | None:
|
||||
"""관 측점과 단면 측점이 소수점에서 어긋날 수 있어 **가까운 것**을 본다.
|
||||
|
||||
⚠ 좁게 본다(기본 0.5m) — 넓히면 옆 측점의 길이를 물어 와 조용히 틀린다.
|
||||
"""
|
||||
if not lengths:
|
||||
return None
|
||||
key = round(chainage, 3)
|
||||
if key in lengths:
|
||||
return lengths[key]
|
||||
best = min(lengths, key=lambda x: abs(x - chainage))
|
||||
return lengths[best] if abs(best - chainage) <= tolerance else None
|
||||
|
||||
|
||||
def build_rows(
|
||||
pipe_points: list[dict[str, Any]],
|
||||
designs: list[dict[str, Any]],
|
||||
mapping: dict[str, Any] | None = None,
|
||||
section_chainages: list[float] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""관 줄 목록. **값이 서는 줄도, 못 서는 줄도** 함께 낸다.
|
||||
|
||||
`pipe_points` 는 `PipePoint.model_dump()` 또는 같은 모양의 딕셔너리 목록이다.
|
||||
"""
|
||||
table = mapping or {}
|
||||
kind_codes: dict[str, str] = table.get("kind_codes") or {}
|
||||
kind_key = str(table.get("kind_option_key") or "pipe_kind")
|
||||
default_kind = str(table.get("default_kind") or "")
|
||||
length_key = str(table.get("length_key") or "pipe_length_m")
|
||||
diameter_key = str(table.get("diameter_option_key") or "pipe_diameter_mm")
|
||||
|
||||
lengths = _length_by_chainage(designs, length_key)
|
||||
# 횡단이 **있는데 길이가 없는 것**과 **관 자리에 횡단이 없는 것**을 가르기 위한 목록.
|
||||
# 안 주면 종전대로 「[저장]하면 풀림」 하나로만 말한다.
|
||||
sections = [float(x) for x in (section_chainages or [])]
|
||||
rows: list[dict[str, Any]] = []
|
||||
notes: list[str] = []
|
||||
|
||||
for point in pipe_points or []:
|
||||
if str(point.get("facility") or FACILITY_PIPE) != FACILITY_PIPE:
|
||||
continue # 배관이 아닌 시설 — 그쪽 줄은 그쪽이 센다
|
||||
options = point.get("options") or {}
|
||||
chainage = float(point.get("chainage_m") or 0.0)
|
||||
|
||||
stored_kind = str(options.get(kind_key) or "").strip()
|
||||
kind = stored_kind or default_kind
|
||||
code = kind_codes.get(kind)
|
||||
kind_note = ""
|
||||
if not stored_kind and default_kind:
|
||||
kind_note = NOTE_KIND_DEFAULT.format(kind=default_kind)
|
||||
elif stored_kind and code is None:
|
||||
kind_note = NOTE_KIND_UNKNOWN.format(kind=stored_kind)
|
||||
|
||||
length = _nearest(lengths, chainage)
|
||||
# ⚠ **관이 놓인 그 측점**이 있는지를 본다 — 옆 측점이 있는 것은 소용없다.
|
||||
# 실측(2026-09-08 `5601e828`): 관 439.55 근처에 측점 440.0 만 있었고, 0.5m 로
|
||||
# 느슨히 보면 「횡단이 있다」로 읽혀 **「[저장]하면 풀린다」는 거짓 안내**가 떴다.
|
||||
# 길이는 B06 이 **관이 놓인 측점에만** 싣는다(2026-09-08 이웃 오염을 고친 뒤).
|
||||
has_section = not sections or any(
|
||||
abs(x - chainage) <= SECTION_MATCH_TOLERANCE_M for x in sections
|
||||
)
|
||||
blocked_kind = None if length else BLOCKED_LENGTH_MISSING
|
||||
blocked_reason = ""
|
||||
if not length:
|
||||
blocked_reason = NOTE_LENGTH_MISSING if has_section else NOTE_SECTION_MISSING
|
||||
if code is None:
|
||||
blocked_kind = blocked_kind or BLOCKED_LENGTH_MISSING
|
||||
blocked_reason = blocked_reason or kind_note
|
||||
|
||||
rows.append(
|
||||
{
|
||||
"chainage_m": chainage,
|
||||
"work_item_code": code,
|
||||
"kind": kind,
|
||||
"kind_from_default": not stored_kind,
|
||||
# 갈래는 **저장 원본값**만 보낸다 — 「…㎜ 이하」 구간 나누기는 원문을 읽는 쪽 몫.
|
||||
"variant_axis": diameter_key,
|
||||
"variant_value": options.get(diameter_key),
|
||||
"unit": str(table.get("unit") or "m"),
|
||||
"quantity": float(length or 0.0),
|
||||
"blocked_kind": blocked_kind,
|
||||
"blocked_reason": blocked_reason or kind_note,
|
||||
"in_bill": bool(length and code),
|
||||
}
|
||||
)
|
||||
if kind_note and kind_note not in notes:
|
||||
notes.append(kind_note)
|
||||
|
||||
missing = sum(1 for row in rows if not row["in_bill"])
|
||||
if missing:
|
||||
notes.append(f"관 {len(rows)}개 중 {missing}개가 아직 값이 안 섭니다")
|
||||
return {
|
||||
"rows": rows,
|
||||
"notes": notes,
|
||||
"pipe_count": len(rows),
|
||||
"ready_count": sum(1 for row in rows if row["in_bill"]),
|
||||
"length_total_m": round(sum(row["quantity"] for row in rows if row["in_bill"]), 3),
|
||||
}
|
||||
@@ -30,7 +30,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
from functools import lru_cache
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Iterable
|
||||
|
||||
@@ -49,14 +52,82 @@ from common_util.common_util_quantity_spread import spread_by_unit
|
||||
# ── 계수표 — 식에 박지 않고 여기서 고른다 ─────────────────────────────
|
||||
# 돌 뒷길이(㎝)별 원단위. 출처: `original/실무문서/_원단위라이브러리_울진소광.md` 「돌뒷길이별 원단위표」.
|
||||
# ⚠ 60㎝ 돌중량은 원본이 비어 있다 — 지어내지 않고 None 으로 둔다(PLAN 8-8 ㉮).
|
||||
#: ⚠ **일곱 규격**이다 — 품셈 13-4-3·13-4-4 [주]① 이 25·30·35·45·55·60·75 를 다 준다.
|
||||
#: 앞서 네 칸(35·45·55·60)만 들고 25·30 을 35 로, 75 를 60 으로 **접고** 있었다.
|
||||
#: `stone_ton_per_m2`(돌중량)는 **실무 관측값**이라 그 넷에만 있다 — 없는 칸은 `None`.
|
||||
STONE_BACK_LENGTH_TABLE: dict[int, dict[str, float | None]] = {
|
||||
25: {"fill_concrete_m3_per_m2": 0.11, "wedge_stone_m3_per_m2": None, "stone_ton_per_m2": None},
|
||||
30: {"fill_concrete_m3_per_m2": 0.14, "wedge_stone_m3_per_m2": 0.10, "stone_ton_per_m2": None},
|
||||
35: {"fill_concrete_m3_per_m2": 0.16, "wedge_stone_m3_per_m2": 0.12, "stone_ton_per_m2": 0.575},
|
||||
45: {"fill_concrete_m3_per_m2": 0.20, "wedge_stone_m3_per_m2": 0.15, "stone_ton_per_m2": 0.88},
|
||||
55: {"fill_concrete_m3_per_m2": 0.25, "wedge_stone_m3_per_m2": 0.18, "stone_ton_per_m2": 1.10},
|
||||
60: {"fill_concrete_m3_per_m2": 0.27, "wedge_stone_m3_per_m2": 0.20, "stone_ton_per_m2": None},
|
||||
75: {"fill_concrete_m3_per_m2": 0.34, "wedge_stone_m3_per_m2": 0.25, "stone_ton_per_m2": None},
|
||||
}
|
||||
DEFAULT_BACK_LENGTH_CM = 45
|
||||
|
||||
|
||||
#: 돌 종류별 계수표 — 품셈 13-4-3·13-4-4 [주]① · 교본 7-3.
|
||||
#: ⚠ 지금까지 **건설품셈 참고자료 한 벌**(돌 종류로 안 갈리는 표)로만 돌고 있었다.
|
||||
#: 그 값이 「깬돌」 계열이라, **자재로는 야면석을 내면서 계수는 깬돌**을 쓰는 어긋남이
|
||||
#: 있었다(2026-09-08 지식DB 대조). 랩탑 창이 `stone_kind` 칸을 만들어 축이 생겼다.
|
||||
STONE_KIND_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_masonry"
|
||||
STONE_KIND_PREFIX = "stone_kind_"
|
||||
STONE_KIND_OPTION = "stone_kind"
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def load_stone_kind_table() -> dict[str, Any]:
|
||||
"""돌 종류별 계수표. 파일이 없으면 **빈 표** — 그러면 종전 값으로 돈다."""
|
||||
folder = STONE_KIND_DIR
|
||||
if not folder.is_dir():
|
||||
return {}
|
||||
files = sorted(folder.glob(STONE_KIND_PREFIX + "*.json"))
|
||||
if not files:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(files[-1].read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def stone_coefficients(options: dict[str, Any], back_cm: int) -> tuple[dict[str, Any], str]:
|
||||
"""(계수 한 벌, 알림). 돌 종류를 안 고르면 **종전 값**으로 돌되 그 사실을 알린다.
|
||||
|
||||
⚠ 값을 못 낸다고 멈추지 않는다 — 이미 저장된 프로젝트가 통째로 비어 버린다.
|
||||
⚠ 표에 「-」(그 규격에 그 돌을 안 씀)면 **지어내지 않고** 사유를 낸다.
|
||||
"""
|
||||
table = load_stone_kind_table()
|
||||
key = str(back_cm)
|
||||
kind = str(options.get(STONE_KIND_OPTION) or "").strip()
|
||||
if not table:
|
||||
return {}, ""
|
||||
if key not in [str(x) for x in (table.get("back_lengths_cm") or [])]:
|
||||
return {}, (f"뒷길이 {back_cm}㎝ 는 품셈 표(25·30·35·45·55·60·75㎝)에 없어 계수가 없습니다")
|
||||
if not kind:
|
||||
fallback = table.get("fallback") or {}
|
||||
return {
|
||||
"wedge_stone_m3_per_m2": (fallback.get("wedge_stone_m3_per_m2") or {}).get(key),
|
||||
"fill_concrete_m3_per_m2": (fallback.get("fill_concrete_m3_per_m2") or {}).get(key),
|
||||
"backfill_ratio": fallback.get("backfill_ratio_of_back_length"),
|
||||
"kind": "",
|
||||
}, str(fallback.get("message") or "")
|
||||
if kind not in (table.get("kinds") or []):
|
||||
return {}, f"「{kind}」은(는) 아는 돌 종류가 아니라 계수를 못 골랐습니다"
|
||||
wedge = ((table.get("wedge_stone_m3_per_m2") or {}).get(kind) or {}).get(key)
|
||||
fill = ((table.get("fill_concrete_m3_per_m2") or {}).get(kind) or {}).get(key)
|
||||
ratio = (table.get("backfill_ratio_of_back_length") or {}).get(kind)
|
||||
note = ""
|
||||
if wedge is None:
|
||||
note = f"품셈 13-4-3 에 「{kind} · 뒷길이 {back_cm}㎝」 칸이 비어 있습니다 — 그 규격에 그 돌을 쓰지 않습니다"
|
||||
return {
|
||||
"wedge_stone_m3_per_m2": wedge,
|
||||
"fill_concrete_m3_per_m2": fill,
|
||||
"backfill_ratio": ratio,
|
||||
"kind": kind,
|
||||
}, note
|
||||
|
||||
|
||||
# 돌쌓기 전개식의 상수 — 실무 수식에 박혀 있던 값을 뺀 것.
|
||||
STONE_MASONRY = {
|
||||
# ⚠ **곱하는 값이 아니라 검산 참고값이다** (2026-09-08 ㉘ 에서 고침).
|
||||
@@ -155,11 +226,11 @@ BACK_LENGTH_KEYS = ("back_len_cm", "stone_back_length_cm")
|
||||
|
||||
|
||||
def _back_length(options: dict[str, Any]) -> int:
|
||||
"""뒷길이(㎝). 표에 없는 값이면 **가장 가까운 아래 칸**이 아니라 기본으로 간다.
|
||||
"""저장된 뒷길이(㎝)를 **그대로** 돌려준다. 안 정했으면 기본 45.
|
||||
|
||||
⚠ 표는 35·45·55·60 네 칸뿐인데 레지스트리 선택지는 25·30·35·45·55·60·75 일곱이다.
|
||||
25·30 은 35 로, 75 는 60 으로 접는다 — **원단위표가 그 구간을 그렇게 덮는다**
|
||||
(품셈 13-4 뒷길이 표준). 접은 사실은 근거 문구에 적는다.
|
||||
⚠ **접지 않는다.** 품셈 13-4-3·13-4-4 [주]① 이 25·30·35·45·55·60·75 **일곱 규격**을
|
||||
다 주므로 접을 까닭이 없다. 그 밖의 값(40 등)은 **계수가 없다고 드러낸다** —
|
||||
접으면 다른 규격 계수가 조용히 돈다.
|
||||
"""
|
||||
for key in BACK_LENGTH_KEYS:
|
||||
raw = options.get(key)
|
||||
@@ -169,13 +240,10 @@ def _back_length(options: dict[str, Any]) -> int:
|
||||
value = int(float(raw))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if value in STONE_BACK_LENGTH_TABLE:
|
||||
return value
|
||||
# 표에 없는 값 — 그 값을 덮는 **가장 가까운 위 칸**으로 접는다(작은 돌이 큰 칸에 들어감).
|
||||
larger = [cm for cm in sorted(STONE_BACK_LENGTH_TABLE) if cm >= value]
|
||||
if larger:
|
||||
return larger[0]
|
||||
return max(STONE_BACK_LENGTH_TABLE)
|
||||
# ⚠ **접지 않는다.** 앞서 「가장 가까운 위 칸」으로 접고 있었는데, 그러면 40㎝ 가
|
||||
# 45㎝ 계수로 **조용히** 돌고 999㎝ 도 60㎝ 로 접혔다(2026-09-08 실측).
|
||||
# 표에 없으면 그 값을 그대로 돌려주고, 계수를 고르는 쪽이 「없다」고 드러낸다.
|
||||
return value
|
||||
return DEFAULT_BACK_LENGTH_CM
|
||||
|
||||
|
||||
@@ -304,7 +372,34 @@ def stone_masonry(
|
||||
return [], ["높이·연장이 없어 전개하지 않음"]
|
||||
|
||||
back_cm = _back_length(options)
|
||||
table = STONE_BACK_LENGTH_TABLE[back_cm]
|
||||
if back_cm not in STONE_BACK_LENGTH_TABLE:
|
||||
# ⚠ 접지 않는다 — 다른 규격 계수가 조용히 도는 것보다 「없다」가 낫다.
|
||||
return [], [
|
||||
f"뒷길이 {back_cm}㎝ 는 품셈 표(25·30·35·45·55·60·75㎝)에 없어 물량이 서지 않습니다"
|
||||
]
|
||||
table = dict(STONE_BACK_LENGTH_TABLE[back_cm])
|
||||
# ⚠ **돌 종류로 계수가 갈린다** (품셈 13-4-3 · 13-4-4 [주]① · 교본 7-3).
|
||||
# 안 고르면 종전 값(건설품셈 참고자료)으로 돌되 그 사실을 알린다 — 값을 못 낸다고
|
||||
# 멈추면 이미 저장된 프로젝트가 통째로 빈다.
|
||||
picked, kind_note = stone_coefficients(options, back_cm)
|
||||
if kind_note:
|
||||
notes.append(kind_note)
|
||||
# ⚠ **고른 종류의 빈 칸은 빈 칸으로 덮는다.** `None` 이라고 안 덮으면 종전 값(깬돌 계열)이
|
||||
# 남아 「야면석 75㎝」처럼 **원문에 「-」인 칸에 값이 서는** 일이 생긴다(만들다 잡음).
|
||||
if picked.get("kind"):
|
||||
for key in ("wedge_stone_m3_per_m2", "fill_concrete_m3_per_m2"):
|
||||
table[key] = picked.get(key)
|
||||
else:
|
||||
for key in ("wedge_stone_m3_per_m2", "fill_concrete_m3_per_m2"):
|
||||
if picked.get(key) is not None:
|
||||
table[key] = picked[key]
|
||||
#: ⚠ **표는 「뒤채움 몫」, 우리 식은 「빼는 몫」** — 뜻이 반대라 1 에서 뺀다.
|
||||
#: 교본은 「뒤채움 = 뒷길이 × (깬돌·잡석 1/2, 야면석 1/3)」이고, 우리 식이 입적에서
|
||||
#: 빼는 것은 **돌 몸통**이라 `1 − 뒤채움몫` 이다. 종전 2/3 이 곧 야면석(1 − 1/3)이었다.
|
||||
#: ⚠ 그대로 넣었더니 미지정 값이 15.130 → 19.045 로 바뀌었다(만들다 잡음).
|
||||
backfill_share = picked.get("backfill_ratio")
|
||||
body_ratio = 1.0 - float(backfill_share) if backfill_share is not None else 2.0 / 3.0
|
||||
kind_label = str(picked.get("kind") or "")
|
||||
# ⚠ `face_slope_ratio` 는 **레지스트리에 없는 키**다 — 즉 지금은 늘 기본 0.3 으로 돈다.
|
||||
# 상수로 두는 것이 아니라 「칸이 생기면 바로 받는다」는 뜻으로 남겨 둔다.
|
||||
# (키 이름 어긋남으로 저장값이 안 닿던 `back_len_cm` 사고와 구별할 것 — 이쪽은 **칸 자체가 없다**.)
|
||||
@@ -332,14 +427,27 @@ def stone_masonry(
|
||||
DESTINATION["돌쌓기"],
|
||||
f"정면적 × √(1+{slope_ratio}²) — 비탈면적",
|
||||
),
|
||||
Component(
|
||||
"고임돌",
|
||||
"㎥",
|
||||
masonry_area * _num(table["wedge_stone_m3_per_m2"]),
|
||||
DESTINATION["고임돌"],
|
||||
f"돌쌓기 × {table['wedge_stone_m3_per_m2']} ㎥/㎡ (뒷길이 {back_cm}㎝)",
|
||||
),
|
||||
]
|
||||
# ⚠ 고임돌 계수가 **원문에서 「-」**인 칸이 있다(견치돌 25·30 · 야면석 75 · 깬돌 25).
|
||||
# 그 규격에 그 돌을 안 쓴다는 뜻이라 **0 줄을 만들지 않는다** — 0 은 「없음」과
|
||||
# 구별이 안 되고, 받는 쪽이 「값이 0 인 자재」로 읽는다.
|
||||
if table["wedge_stone_m3_per_m2"] is None:
|
||||
notes.append(
|
||||
f"고임돌 계수가 품셈 표에 없습니다 — 뒷길이 {back_cm}㎝"
|
||||
+ (f" · {kind_label}" if kind_label else "")
|
||||
+ " 칸이 「-」입니다"
|
||||
)
|
||||
else:
|
||||
components.append(
|
||||
Component(
|
||||
"고임돌",
|
||||
"㎥",
|
||||
masonry_area * _num(table["wedge_stone_m3_per_m2"]),
|
||||
DESTINATION["고임돌"],
|
||||
f"돌쌓기 × {table['wedge_stone_m3_per_m2']} ㎥/㎡ (뒷길이 {back_cm}㎝)"
|
||||
+ (f" · {kind_label}" if kind_label else ""),
|
||||
)
|
||||
)
|
||||
|
||||
stone_ton = table["stone_ton_per_m2"]
|
||||
if stone_ton is None:
|
||||
@@ -356,13 +464,19 @@ def stone_masonry(
|
||||
)
|
||||
)
|
||||
|
||||
# 막자갈 = 입적 − (면적 × 뒷길이 × 2/3 + 고임돌). 실무 식 그대로.
|
||||
wedge = masonry_area * _num(table["wedge_stone_m3_per_m2"])
|
||||
rubble = volume - (masonry_area * (back_cm / 100.0) * 2.0 / 3.0 + wedge)
|
||||
# 막자갈 = 입적 − (면적 × 뒷길이 × 뒤채움몫 + 고임돌).
|
||||
# 뒤채움 몫은 **돌 종류로 갈린다** — 깬돌·잡석 1/2 · 야면석 1/3 (교본 7-3).
|
||||
wedge = masonry_area * _num(table["wedge_stone_m3_per_m2"]) # None 이면 0 — 막자갈에서 안 뺌
|
||||
rubble = volume - (masonry_area * (back_cm / 100.0) * body_ratio + wedge)
|
||||
if rubble > 0:
|
||||
components.append(
|
||||
Component(
|
||||
"막자갈", "㎥", rubble, DESTINATION["막자갈"], "입적 − (면적×뒷길이×2/3 + 고임돌)"
|
||||
"막자갈",
|
||||
"㎥",
|
||||
rubble,
|
||||
DESTINATION["막자갈"],
|
||||
f"입적 − (면적×뒷길이×{body_ratio:.4g} + 고임돌)"
|
||||
+ (f" · {kind_label}" if kind_label else ""),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -83,6 +83,18 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
|
||||
settings, project_root = await _project_settings(project_id)
|
||||
plan = await _stored_haul_plan(project_id, route_id)
|
||||
haul = build_haul_table(plan)
|
||||
# 배수관 연장 — B06 이 측점 `design.pipe_length_m` 에 남긴 값. **여기서 짓지 않는다.**
|
||||
# 인계가 관 줄을 세울 때 쓴다. 단면을 두 번 읽지 않으려고 이 응답에 실어 보낸다.
|
||||
table["pipe_lengths"] = [
|
||||
{
|
||||
"chainage_m": row.get("chainage_m"),
|
||||
"pipe_length_m": (row.get("design") or {}).get("pipe_length_m"),
|
||||
}
|
||||
for row in designs
|
||||
if isinstance(row, dict) and (row.get("design") or {}).get("pipe_length_m") is not None
|
||||
]
|
||||
# 횡단이 선 측점 목록 — 관 줄이 「길이가 없음」과 「횡단 자체가 없음」을 가르는 데 쓴다.
|
||||
table["section_chainages"] = [row.get("chainage_m") for row in designs if isinstance(row, dict)]
|
||||
table["haul"] = haul
|
||||
# 운반계획은 [저장]·[확정]에서 정본에 남는 값이다 — 아직 없으면 빈 표가 정직하다.
|
||||
table["haul_available"] = bool(plan)
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
@@ -25,7 +27,7 @@ from fastapi.responses import JSONResponse
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B05_Profile.B05_Profile_Structures_Repository import load_structures
|
||||
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
|
||||
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff, summarize
|
||||
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff, load_mapping, summarize
|
||||
from B08_Quantity.B08_Quantity_Engine_MaterialSummary import build_table as build_material_table
|
||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table
|
||||
from common_util.common_util_project_settings import (
|
||||
@@ -35,6 +37,7 @@ from common_util.common_util_project_settings import (
|
||||
rock_method,
|
||||
)
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_structure_lengths import structure_lengths
|
||||
from config.config_db import run_with_connection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -66,6 +69,11 @@ def _collect_structures(
|
||||
if definition.reference_only:
|
||||
skipped.append(f"{definition.name}: 전문 상세설계 대상 — 배치까지만")
|
||||
continue
|
||||
if definition.group == "B":
|
||||
# ⚠ B군(종단배수)은 **연장표**로 간다 — `common_util_structure_lengths` 가
|
||||
# 겹친 구간을 합쳐 주기 때문이다. 구조물별로 세면 겹친 구간을 두 번 센다.
|
||||
# 여기서 빼지 않으면 **같은 시설이 두 줄로** 나간다.
|
||||
continue
|
||||
targets.append(payload)
|
||||
return targets, names, sorted(set(skipped))
|
||||
|
||||
@@ -161,6 +169,14 @@ async def get_handoff(project_id: UUID) -> JSONResponse:
|
||||
material_table=material_table,
|
||||
# 준비공·사방공 — 값이 서는 줄도, 못 내는 줄도 함께 넘긴다(빼면 빠진 줄이 안 보임).
|
||||
preparation_table=earthwork.get("preparation"),
|
||||
# B군 종단배수 — 겹침을 합친 연장. 그 규칙이 이미 그 함수에 있어 두 벌로 안 짠다.
|
||||
length_table=[row for row in structure_lengths(project_root) if row.get("group") == "B"],
|
||||
# 배수관 — 관 정본은 `pipe_points.json`, 연장은 측점 `design.pipe_length_m` 다.
|
||||
pipe_table=_pipe_table(
|
||||
project_root,
|
||||
earthwork.get("pipe_lengths") or [],
|
||||
earthwork.get("section_chainages") or [],
|
||||
),
|
||||
ground_class_set=settings.get("rock_class_set"),
|
||||
ground_classes=rock_classes(settings),
|
||||
ground_methods={name: rock_method(settings, name) for name in rock_classes(settings)},
|
||||
@@ -173,6 +189,47 @@ async def get_handoff(project_id: UUID) -> JSONResponse:
|
||||
return JSONResponse(content=handoff)
|
||||
|
||||
|
||||
def _pipe_table(
|
||||
project_root: str,
|
||||
pipe_lengths: list[dict[str, Any]],
|
||||
section_chainages: list[Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""배수관 표 — 정본 셋을 읽어 잇는다. 못 읽으면 **빈 표**(줄이 안 서는 것이 정직하다).
|
||||
|
||||
⚠ 관 정본은 `structures.json` 이 아니라 `pipe_points.json` 이다
|
||||
(레지스트리 `pipe` 타입이 `managed_by: pipe_points`).
|
||||
"""
|
||||
from B08_Quantity.B08_Quantity_Engine_Pipe import build_rows as build_pipe_rows
|
||||
from common_util.common_util_drainage_pipes import pipe_points_path_in
|
||||
|
||||
path = pipe_points_path_in(Path(project_root))
|
||||
if not path.is_file():
|
||||
return {"rows": [], "notes": [], "pipe_count": 0, "ready_count": 0, "length_total_m": 0.0}
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
logger.exception("B08 관 지점 읽기 실패: %s", path)
|
||||
return {
|
||||
"rows": [],
|
||||
"notes": ["관 지점 파일을 읽지 못했습니다"],
|
||||
"pipe_count": 0,
|
||||
"ready_count": 0,
|
||||
"length_total_m": 0.0,
|
||||
}
|
||||
points = payload.get("points") or payload.get("items") or []
|
||||
# 토적표 라우터가 실어 준 모양을 엔진이 읽는 모양으로 옮긴다.
|
||||
designs = [
|
||||
{"chainage_m": row.get("chainage_m"), "design": {"pipe_length_m": row.get("pipe_length_m")}}
|
||||
for row in pipe_lengths
|
||||
]
|
||||
return build_pipe_rows(
|
||||
points,
|
||||
designs,
|
||||
(load_mapping().pipe or {}),
|
||||
[float(x) for x in (section_chainages or []) if x is not None],
|
||||
)
|
||||
|
||||
|
||||
async def _earthwork_tables(project_id: UUID) -> dict[str, Any]:
|
||||
"""토적표 라우터가 만든 집계·운반 표를 얻는다. 노선이 없으면 빈 값."""
|
||||
from B08_Quantity.B08_Quantity_Router_Earthwork import (
|
||||
|
||||
@@ -331,7 +331,13 @@ export function attachCulvertSets(
|
||||
let reach = CHAINAGE_TOLERANCE_M;
|
||||
if (SPAN_LINKED_TYPES.has(String(spec.type))) reach += (num(spec.span_m, 0) ?? 0) / 2;
|
||||
if (Math.abs(chainage - pipeChainage) <= reach) {
|
||||
section[SECTION_KEYS[String(spec.type)] ?? "culvert"] = spec;
|
||||
// ⚠ 스펙에 **그 시설이 놓인 누가거리**를 함께 얹는다(2026-09-08, 짝: 파이썬
|
||||
// `attach_culvert_sets`). 세트는 폭의 절반까지 옆 측점에도 붙으므로, 이것이 없으면
|
||||
// 소비처가 「소유 측점」을 못 가려 **같은 시설을 여러 측점에서 센다**.
|
||||
section[SECTION_KEYS[String(spec.type)] ?? "culvert"] = {
|
||||
...spec,
|
||||
chainage_m: pipeChainage,
|
||||
};
|
||||
attached += 1;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -23,14 +23,17 @@ from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
|
||||
PENDING_TYPE_IDS: frozenset[str] = frozenset()
|
||||
|
||||
|
||||
def _merge(spans: list[tuple[float, float]]) -> float:
|
||||
"""겹치는 구간을 합쳐 실제 덮인 길이를 낸다.
|
||||
def _merge(spans: list[tuple[float, float]]) -> list[tuple[float, float]]:
|
||||
"""겹치는 구간을 **합쳐** 실제 덮인 구간 목록을 낸다.
|
||||
|
||||
같은 시설을 겹치게 두 번 넣으면 단순 합은 그 구간을 **두 번 센다**. 연장은 「덮인
|
||||
길이」라 겹침을 지우는 쪽이 맞다. 원래 합(`raw_length_m`)도 함께 내보내므로 입력이
|
||||
겹쳤다는 사실은 숨지 않는다.
|
||||
|
||||
합친 **구간 자체**를 돌려준다 — 길이만 내면 산출근거에 「어디부터 어디까지」를 못 적는다
|
||||
(2026-09-08 B08 창 요청). 길이는 부르는 쪽이 이 목록에서 더한다.
|
||||
"""
|
||||
total = 0.0
|
||||
merged: list[tuple[float, float]] = []
|
||||
current_start: float | None = None
|
||||
current_end = 0.0
|
||||
for start, end in sorted(spans):
|
||||
@@ -40,11 +43,11 @@ def _merge(spans: list[tuple[float, float]]) -> float:
|
||||
if start <= current_end:
|
||||
current_end = max(current_end, end)
|
||||
continue
|
||||
total += current_end - current_start
|
||||
merged.append((current_start, current_end))
|
||||
current_start, current_end = start, end
|
||||
if current_start is not None:
|
||||
total += current_end - current_start
|
||||
return total
|
||||
merged.append((current_start, current_end))
|
||||
return merged
|
||||
|
||||
|
||||
def structure_lengths(project_root: str | Path) -> list[dict[str, Any]]:
|
||||
@@ -78,6 +81,7 @@ def structure_lengths(project_root: str | Path) -> list[dict[str, Any]]:
|
||||
rows: list[dict[str, Any]] = []
|
||||
for type_id, count in counts.items():
|
||||
entries = spans[type_id]
|
||||
merged = _merge(entries)
|
||||
rows.append(
|
||||
{
|
||||
"type_id": type_id,
|
||||
@@ -85,9 +89,14 @@ def structure_lengths(project_root: str | Path) -> list[dict[str, Any]]:
|
||||
"name": types[type_id].name,
|
||||
"count": count,
|
||||
# 겹침을 지운 실제 연장 — 수량서에 쓸 값.
|
||||
"length_m": round(_merge(entries), 2),
|
||||
"length_m": round(sum(end - start for start, end in merged), 2),
|
||||
# 입력한 구간 길이의 단순 합 — 위와 다르면 구간이 겹쳐 있다는 뜻.
|
||||
"raw_length_m": round(sum(end - start for start, end in entries), 2),
|
||||
# 겹침을 지운 **구간 목록**(누가거리 m) — 산출근거에 「어디부터 어디까지」를
|
||||
# 적는 자리다. 길이는 이 목록의 합과 같다(2026-09-08 B08 창 요청).
|
||||
"spans": [
|
||||
{"start_m": round(start, 2), "end_m": round(end, 2)} for start, end in merged
|
||||
],
|
||||
}
|
||||
)
|
||||
rows.sort(key=lambda row: (row["group"], row["name"]))
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"dataset_id": "stone_kind_unit",
|
||||
"effective_date": "2026-01-01",
|
||||
"note": "돌쌓기 계수는 **돌 종류로 갈린다**. 지금까지 한 벌(깬돌 계열)로만 돌고 있었다 — 2026-09-08 지식DB 대조에서 드러났다.",
|
||||
"why": "품셈 13-4-3(고임돌)은 돌 종류로 **네 줄**, 13-4-4 [주]①(채움 콘크리트)은 **두 줄**, 교본 7-3 뒤채움은 **두 값**이다. 세 곳이 같은 축인데 우리는 축 없이 하나로 돌고 있었다.",
|
||||
"option_key": "stone_kind",
|
||||
"option_note": "레지스트리 `masonry_wet`·`masonry_dry` 의 옵션. 2026-09-08 랩탑 창이 만들었고 키 이름을 두 창이 미리 맞췄다(`back_len_cm` 이름 어긋남 사고를 되풀이하지 않으려고).",
|
||||
"sources": {
|
||||
"forest_13_4_3": {
|
||||
"doc": "산림사업 표준품셈 13-4-3 고임돌 소요량 (단위: ㎥/㎡당)",
|
||||
"note": "돌 종류 네 줄. 「-」는 그 규격에 그 돌을 안 쓴다는 뜻이라 지어내지 않는다."
|
||||
},
|
||||
"forest_13_4_4_note1": {
|
||||
"doc": "산림사업 표준품셈 13-4-4 [주]① 찰쌓기 및 찰붙임의 채움 콘크리트 소요량 (㎥/㎡당)",
|
||||
"note": "두 줄뿐 — 야면석·호박돌(뒷길이의 33.3 %) / 깬잡석·깬돌·견치돌(45 %)."
|
||||
},
|
||||
"textbook_7_3": {
|
||||
"doc": "임도기술교본 7장 3절 (지식DB 돌쌓기.md §4 · 흙막이.md §4)",
|
||||
"quote": "뒤채움 = 돌쌓기 표면적 × 뒷길이 × (깬돌·잡석 1/2, 야면석 1/3)"
|
||||
},
|
||||
"construction_reference": {
|
||||
"doc": "건설공사 표준품셈 [참고자료] 돌쌓기 규격별 소요량",
|
||||
"note": "⚠ **돌 종류로 안 갈리는 한 벌**이고, 그 값이 우리가 지금까지 쓰던 값이다(고임돌 = 깬돌 줄 · 채움 콘크리트 0.16/0.20/0.25/0.27). 2026 개정안이 이 참고자료를 **「삭제 검토」** 로 두었다."
|
||||
}
|
||||
},
|
||||
"policy": {
|
||||
"primary": "산림사업이므로 **산림품셈이 1차 적용**(CLAUDE.md 3장). 건설품셈 값은 미지정일 때의 기본값 근거로만 쓴다.",
|
||||
"unset_is_flagged": "돌 종류를 안 고르면 **건설품셈 참고자료 한 벌**로 돌되 그 사실을 알린다 — 값을 못 낸다고 멈추면 이미 저장된 프로젝트가 통째로 빈다.",
|
||||
"no_interpolation": "표에 없는 뒷길이·「-」 칸은 **지어내지 않는다.**"
|
||||
},
|
||||
"kinds": [
|
||||
"야면석·호박돌",
|
||||
"깬잡석",
|
||||
"깬돌",
|
||||
"견치돌"
|
||||
],
|
||||
"wedge_stone_m3_per_m2": {
|
||||
"note": "고임돌 — 품셈 13-4-3. 키는 뒷길이(㎝). `null` 은 원문 「-」.",
|
||||
"야면석·호박돌": {
|
||||
"25": 0.06,
|
||||
"30": 0.07,
|
||||
"35": 0.09,
|
||||
"45": 0.11,
|
||||
"55": 0.14,
|
||||
"60": 0.15,
|
||||
"75": null
|
||||
},
|
||||
"깬잡석": {
|
||||
"25": 0.09,
|
||||
"30": 0.11,
|
||||
"35": 0.13,
|
||||
"45": 0.16,
|
||||
"55": 0.19,
|
||||
"60": 0.21,
|
||||
"75": 0.26
|
||||
},
|
||||
"깬돌": {
|
||||
"25": null,
|
||||
"30": 0.1,
|
||||
"35": 0.12,
|
||||
"45": 0.15,
|
||||
"55": 0.18,
|
||||
"60": 0.2,
|
||||
"75": 0.25
|
||||
},
|
||||
"견치돌": {
|
||||
"25": null,
|
||||
"30": null,
|
||||
"35": 0.12,
|
||||
"45": 0.15,
|
||||
"55": 0.18,
|
||||
"60": 0.2,
|
||||
"75": 0.25
|
||||
}
|
||||
},
|
||||
"fill_concrete_m3_per_m2": {
|
||||
"note": "채움 콘크리트 — 품셈 13-4-4 [주]①. **두 줄뿐**이라 견치돌·깬잡석·깬돌이 한 값을 쓴다.",
|
||||
"야면석·호박돌": {
|
||||
"25": 0.08,
|
||||
"30": 0.1,
|
||||
"35": 0.12,
|
||||
"45": 0.15,
|
||||
"55": 0.18,
|
||||
"60": 0.2,
|
||||
"75": 0.25
|
||||
},
|
||||
"깬잡석": {
|
||||
"25": 0.11,
|
||||
"30": 0.14,
|
||||
"35": 0.16,
|
||||
"45": 0.2,
|
||||
"55": 0.25,
|
||||
"60": 0.27,
|
||||
"75": 0.34
|
||||
},
|
||||
"깬돌": {
|
||||
"25": 0.11,
|
||||
"30": 0.14,
|
||||
"35": 0.16,
|
||||
"45": 0.2,
|
||||
"55": 0.25,
|
||||
"60": 0.27,
|
||||
"75": 0.34
|
||||
},
|
||||
"견치돌": {
|
||||
"25": 0.11,
|
||||
"30": 0.14,
|
||||
"35": 0.16,
|
||||
"45": 0.2,
|
||||
"55": 0.25,
|
||||
"60": 0.27,
|
||||
"75": 0.34
|
||||
}
|
||||
},
|
||||
"backfill_ratio_of_back_length": {
|
||||
"note": "뒤채움이 뒷길이에서 차지하는 몫 — 교본 7-3. 나머지가 잡석(막자갈)이 된다.",
|
||||
"야면석·호박돌": 0.3333333333333333,
|
||||
"깬잡석": 0.5,
|
||||
"깬돌": 0.5,
|
||||
"견치돌": 0.5
|
||||
},
|
||||
"fallback": {
|
||||
"note": "돌 종류 미지정일 때 — 건설품셈 [참고자료] 돌쌓기 규격별 소요량 한 벌(고임돌은 「깬돌」 열). ⚠ 일곱 규격을 다 싣는다 — 앞서 네 칸만 들고 있어 25·30·75 를 가까운 칸으로 **접어 올리고** 있었다.",
|
||||
"wedge_stone_m3_per_m2": {
|
||||
"25": null,
|
||||
"30": 0.1,
|
||||
"35": 0.12,
|
||||
"45": 0.15,
|
||||
"55": 0.18,
|
||||
"60": 0.2,
|
||||
"75": 0.25
|
||||
},
|
||||
"fill_concrete_m3_per_m2": {
|
||||
"25": 0.11,
|
||||
"30": 0.14,
|
||||
"35": 0.16,
|
||||
"45": 0.2,
|
||||
"55": 0.25,
|
||||
"60": 0.27,
|
||||
"75": 0.34
|
||||
},
|
||||
"backfill_ratio_of_back_length": 0.3333333333333333,
|
||||
"backfill_note": "⚠ 이 값은 **뒤채움이 차지하는 몫**이다. 우리 막자갈 식은 입적에서 **돌 몸통**을 빼므로 코드가 `1 − 이 값` 을 쓴다. 종전 하드코딩 2/3 이 곧 야면석(1 − 1/3)이었다.",
|
||||
"message": "돌 종류를 안 정해 건설품셈 참고자료 값으로 섰습니다 — 구조물 상세 입력에서 고르면 산림품셈 계수로 바뀝니다"
|
||||
},
|
||||
"not_here": {
|
||||
"note": "이 표가 정하지 않는 것.",
|
||||
"items": [
|
||||
"돌중량(ton/㎡) — 우리 값 0.575/0.88/1.10 은 **실무 관측**(울진 라이브러리)이다. 품셈은 「돌의 중량은 형상·종류·부피를 고려하고 건설품셈 1-3-3 재료의 단위중량을 참고하여 계상한다」로만 두어 **표를 안 준다.** 돌 종류 축과 짝이 맞는지 사용자 확정 대기.",
|
||||
"전면면적이 정면적인지 비탈면적인지 — 2026 개정안 [주] 「시공량은 석재의 **전면면적**(㎡)을 기준한다」로 용어만 확인됐다."
|
||||
]
|
||||
},
|
||||
"back_lengths_cm": [
|
||||
25,
|
||||
30,
|
||||
35,
|
||||
45,
|
||||
55,
|
||||
60,
|
||||
75
|
||||
],
|
||||
"no_folding": "⚠ 표에 없는 뒷길이를 **가까운 칸으로 접지 않는다.** 접으면 40㎝ 가 45㎝ 계수로 조용히 돌고 999㎝ 도 60㎝ 로 접혔다(2026-09-08 실측). 표에 없으면 **계수가 없다고 드러낸다.**"
|
||||
}
|
||||
@@ -278,6 +278,13 @@
|
||||
"height_m": 1.6
|
||||
},
|
||||
"why": "울진 2공구에 H=1.6 이 실재하나 수치가 라이브러리에 없음. H=2.0 값을 비례로 줄이지 않음 — 기초·벽체는 높이에 비례하지 않음."
|
||||
},
|
||||
{
|
||||
"type_id": "retaining_wall",
|
||||
"about": "기초잡석",
|
||||
"why": "⚠ **우리 표가 빠뜨린 것이 아니라 원문에 없음**(2026-09-08 전수 확인). 울진 라이브러리 §7 「반중력식옹벽 H=2.0」 원문은 「콘크리트 1.35㎥(기초 0.75+벽체 0.60) + 버림 0.15㎥, 유로폼 3.20㎡, 기초 거푸집 0.6㎡, 물빼기 파이프 Ø50 0.32m, 철근 D13 13.45㎏ + D16 30.42㎏」이 전부다 — 기초잡석·터파기·되메우기·잔토가 다 없다.",
|
||||
"note": "기초잡석이 있는 시트는 **§1 관보호공 날개벽**뿐이다(T=0.2 · Ø800 A-TYPE 1.15㎥/개소 등). 옹벽에 그 값을 옮겨 쓰면 **다른 구조물 값을 갖다 쓰는 것**이라 하지 않는다.",
|
||||
"needs": "옹벽 기초잡석을 계상할지 · 하면 두께를 얼마로 볼지 — 사용자 확정. 품셈 12-25 는 「기초잡석 운반·부설·다짐」 품만 주고 **두께를 정하지 않는다**(㎥당)."
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -150,6 +150,24 @@
|
||||
},
|
||||
"class_note": "메/찰(`bond`)은 **공종 자체가 갈리는 의미 판정**이라 여기서 고른다. 직경 갈래는 `variant_value` 로 원본값만 보낸다 — 원문이 물결표를 섞어 써서.",
|
||||
"variant_axis": "stone_cm"
|
||||
},
|
||||
{
|
||||
"type_id": "ditch_ridge",
|
||||
"work_item_code": "FP-12-09-02",
|
||||
"master_name": "측구 > 산마루 측구",
|
||||
"note": "품셈 12-9-2 · 밑수 1 m — 구조물 연장(m)이 그대로 수량이다. 2026-09-08 V-3 확인에서 이었다(그전에는 매핑이 없어 연장은 오는데 공종코드가 비어 있었다)."
|
||||
},
|
||||
{
|
||||
"type_id": "ditch_berm",
|
||||
"work_item_code": "FP-12-09-03",
|
||||
"master_name": "측구 > 소단 측구",
|
||||
"note": "품셈 12-9-3 · 밑수 1 m"
|
||||
},
|
||||
{
|
||||
"type_id": "underdrain",
|
||||
"work_item_code": "FP-12-10",
|
||||
"master_name": "맹암거",
|
||||
"note": "품셈 12-10 · 밑수 1 m"
|
||||
}
|
||||
],
|
||||
"pending_user": {
|
||||
@@ -171,6 +189,18 @@
|
||||
"FP-09-05 발파암"
|
||||
],
|
||||
"why": "설계자가 넣는 암 갈래 이름(풍화암·연암·보통암·경암)이 리핑이냐 발파냐를 말하지 않음. 갈래마다 시공법을 지정하는 칸이 필요함"
|
||||
},
|
||||
{
|
||||
"type_id": "chute",
|
||||
"name": "도수로·산비탈수로",
|
||||
"why": "품셈 12장 측구 계열(12-9-1 L형 · 12-9-2 산마루 · 12-9-3 소단)과 12-10 맹암거에 **해당 공종이 없음**(2026-09-08 마스터 전수). 「수로」로 검색해도 나오는 것은 그 셋뿐임.",
|
||||
"needs": "어느 공종으로 볼지 사용자 확정 — 또는 별도 표준도·일위대가"
|
||||
},
|
||||
{
|
||||
"type_id": "slope_drain",
|
||||
"name": "절토사면 배수로",
|
||||
"why": "위와 같음 — 품셈에 그 이름의 공종이 없음.",
|
||||
"needs": "어느 공종으로 볼지 사용자 확정"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -277,5 +307,27 @@
|
||||
],
|
||||
"b09_does": "원문 표기(물결표·공백·괄호)를 흡수해 자기 키로 옮긴다. 「…㎝ 이하」 구간 나누기도 그쪽 몫 — 그 구간이 **품셈 표의 구조**이기 때문."
|
||||
},
|
||||
"masonry_class_reference": "resources/data_masonry/masonry_class_2026-01-01.json — ⚠ 이제 **참고용**이다. 서브 판정과 어긋나면 그것이 곧 신호다."
|
||||
"masonry_class_reference": "resources/data_masonry/masonry_class_2026-01-01.json — ⚠ 이제 **참고용**이다. 서브 판정과 어긋나면 그것이 곧 신호다.",
|
||||
"pipe": {
|
||||
"note": "배수관(횡단배수관) — **관종으로 공종이 갈린다.** 관은 `structures.json` 이 아니라 `pipe_points.json` 이 정본이고(레지스트리 `pipe` 타입이 `managed_by: pipe_points`), 연장은 B06 횡단이 서버 Node 로 계산해 측점 `design.pipe_length_m` 로 남긴다(2026-09-08 랩탑 창). B08 은 그 셋을 잇기만 한다.",
|
||||
"kind_codes": {
|
||||
"파형강관": "FP-12-11-03",
|
||||
"흄관": "FP-12-11-02",
|
||||
"VR관": "FP-12-11-01"
|
||||
},
|
||||
"kind_option_key": "pipe_kind",
|
||||
"default_kind": "파형강관",
|
||||
"default_is_user_confirmed": true,
|
||||
"default_note": "레지스트리 `pipe` 옵션의 기본값이며 **2026-08-17 사용자 확정**임. 그래도 저장값이 비어 있으면 「관종 미지정」으로 드러내고 기본값으로 돈다는 사실을 함께 싣는다(암 시공법과 같은 처리).",
|
||||
"unit": "m",
|
||||
"length_key": "pipe_length_m",
|
||||
"diameter_option_key": "pipe_diameter_mm",
|
||||
"variant_axis": "pipe_diameter_mm",
|
||||
"facility_rule": "⚠ `pipe_points.json` 은 **계곡 통과 시설 전부의 정본**이다(배관·BOX암거·물넘이·세월교·독립 기슭막이). `facility` 가 `pipe` 인 점만 배관이다 — `pipe_diameter_mm` 유무로 가르면 **관경 미지정 관을 놓친다**(2026-09-08 랩탑 창).",
|
||||
"revetment_note": "⚠ 유출·유입부 기슭막이는 **관 옵션(`inlet_revet_*`·`outlet_revet_*`)이 정본**이다. 레지스트리 `revetment` 타입이 `managed_by: pipe_points` 라 구조물 목록에서 빠졌으므로 **구조물 쪽으로 또 세지 않는다**(2026-08-28 이관).",
|
||||
"not_ready": {
|
||||
"흄관 밑수 두 벌": "`FP-12-11-02` 는 밑수가 「1 m」와 「1 개소」 두 벌이다(표가 둘). B09 가 `#갈래` 로 두 표를 각각 세우므로 B08 은 `variant_value` 로 어느 쪽인지 보내면 된다.",
|
||||
"터파기·되메우기": "⚠ 관 부설과 터파기·되메우기가 각각 오면 **같은 굴착을 두 번 셀 수 있다**(B09 ㉡ 가드). 관 줄에는 지금 터파기를 붙이지 않는다."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
| 19 | [공통] ★STmate 실기 환경 확보 (STC 입출력 검증 전제) | STC = 발주처 제출 최종물, Aislo import/export 필수 요구사항(사용자 확정 2026-08-15). 포맷 명세는 [original/원가계산/STmate/](original/원가계산/STmate/) 정리 완료. **잔여 = STmate 실행 환경** — 생성·변형 STC를 열기→조회→수정→재계산→저장→재개방까지 검증해야 빈 테이블(BDQTY·SYSINFO) export 허용 여부·신규생성 규칙 확정 가능. 환경 없으면 [STC_왕복검증](original/원가계산/STmate/STC_왕복검증.md) R1~R9 실행 불가 | 선택지: ① STmate 보유·구매 ② 체험판 ③ 설계사무소 협조. **개발 단계 사용자 결정** | [STmate 포맷 명세](original/원가계산/STmate/_meta.md), [STC_난독화_상태](original/원가계산/STmate/STC_난독화_상태.md) | 미결 — 사용자 결정 대기 (2026-08-15) |
|
||||
| 20 | [임도] 유토곡선 평형선·극값 임계·balloon 표기 기준 | 법령·행정규칙·표준시방서·교본에 **세부 규칙 없음**(2026-09-03 확인). KDS 44 30 00 2.3.3은 「구간별 균형 배분·운반거리 최소화」까지만 규정. 실무 도면(`유토곡선.dwg`)은 압축 DWG라 표기 문자열 추출 불가 — 표기 관행은 도면 육안 확인 필요. 실무 곡선 원본 6건 관측치는 문서에 정리 | 실무 유토곡선 도면 육안 확인 후 사용자 협의로 확정 | [유토곡선_토량배분](technical_info/01_임도/03_계산정보/유토곡선_토량배분.md) §2·§3 | 미결 — 근거 공백 (2026-09-03) |
|
||||
| 21 | [임도] 토공 운반장비 한계거리 — 도자 60m vs 70m | 실무 `EARTH.DAT` 헤더 6건 전부 `무대 20.0 / 도자 60.0`. Aislo 현행 `EARTHWORK_HAUL_EQUIPMENT_LIMITS_M`은 20/70. 현행 법령·품셈에 장비별 한계거리 규정 없음 — 두 값 모두 후보 | 개발 단계 사용자 협의로 채택값 확정 | [유토곡선_토량배분](technical_info/01_임도/03_계산정보/유토곡선_토량배분.md) §4 | 미결 — 사용자 결정 대기 (2026-09-03) |
|
||||
| 22 | [임도] 옆도랑(측구) 사다리꼴 단면 — 저폭 근거 공백 | 별표2 근거는 **너비 0.5~1 m · 깊이 30 ㎝ 내외**까지뿐([측구 §1](technical_info/01_임도/02_상세설계/측구.md))이고 **저폭 수치는 원문 전수에 없음**. Aislo 현행 0.9/0.3/0.3(상단폭·저폭·깊이)은 표준횡단도면 판독값이며 측벽이 45°라 [통수단면 §4](technical_info/01_임도/03_계산정보/통수단면.md) 경제 단면(사다리형 측벽 **60°**, B≒1.155H)과 어긋남 — 두 축 중 무엇을 기본으로 둘지 미정 | 개발 단계 사용자 협의로 채택 축 확정 (도면 판독값 vs 경제 단면식) | [측구](technical_info/01_임도/02_상세설계/측구.md) §1·[통수단면](technical_info/01_임도/03_계산정보/통수단면.md) §4 | 미결 — 사용자 결정 대기 (2026-09-08) |
|
||||
|
||||
## §2 확인사항 — 임도교본(2019) vs 사방교본(2023) 충돌 리스트 (2026-08-12, 최종검증에서 도출)
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Reference in New Issue
Block a user