Compare commits
20
Commits
sub_desktop_1
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08ff9458ea | ||
|
|
c73654be66 | ||
|
|
16d3461573 | ||
|
|
364c28a700 | ||
|
|
4d33f8d61c | ||
|
|
ac66a25cc7 | ||
|
|
cd6c7f3fed | ||
|
|
b34eb546f9 | ||
|
|
2ad20e5d02 | ||
|
|
9deaf479d0 | ||
|
|
419148901b | ||
|
|
b516ffed66 | ||
|
|
15d91be7b0 | ||
|
|
0fa6cd0898 | ||
|
|
0989471901 | ||
|
|
6ea73956e5 | ||
|
|
8ed4be855b | ||
|
|
20597071bd | ||
|
|
fd0ea82975 | ||
|
|
8dddc76839 |
@@ -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 이 줄을 세움.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 정리).
|
||||||
|
|||||||
@@ -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%)"),
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ 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 (
|
from B08_Quantity.B08_Quantity_Engine_Preparation_Status import (
|
||||||
STATUS_NOT_APPLICABLE as PREP_NOT_APPLICABLE,
|
STATUS_NOT_APPLICABLE as PREP_NOT_APPLICABLE,
|
||||||
)
|
)
|
||||||
@@ -79,7 +82,9 @@ def _preparation_rows(preparation_table: dict[str, Any]) -> list[dict[str, Any]]
|
|||||||
"composite_parts": None,
|
"composite_parts": None,
|
||||||
"structure_kind": None,
|
"structure_kind": None,
|
||||||
# ⚠ 못 서는 까닭을 그대로 넘긴다 — 받는 쪽이 「만들어야 할 것」 목록에 얹는다.
|
# ⚠ 못 서는 까닭을 그대로 넘긴다 — 받는 쪽이 「만들어야 할 것」 목록에 얹는다.
|
||||||
"blocked_kind": None if ready else _prep_blocked_kind(status),
|
"blocked_kind": None
|
||||||
|
if ready
|
||||||
|
else row.get("blocked_kind") or _prep_blocked_kind(status),
|
||||||
"blocked_reason": "" if ready else str(row.get("reason") or status),
|
"blocked_reason": "" if ready else str(row.get("reason") or status),
|
||||||
# ⚠ 준비공 줄도 갈래를 실어 보낸다(2026-09-09) — 종전에는 늘 `None` 이라
|
# ⚠ 준비공 줄도 갈래를 실어 보낸다(2026-09-09) — 종전에는 늘 `None` 이라
|
||||||
# 표토 운반처럼 **부모 공종코드**로 가는 줄이 B09 에서 「후보 N건」에 머물렀다.
|
# 표토 운반처럼 **부모 공종코드**로 가는 줄이 B09 에서 「후보 N건」에 머물렀다.
|
||||||
@@ -105,9 +110,15 @@ 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
|
||||||
|
|||||||
@@ -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,6 +11,7 @@ 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_NEEDS_INPUT,
|
||||||
STATUS_PENDING,
|
STATUS_PENDING,
|
||||||
STATUS_READY,
|
STATUS_READY,
|
||||||
)
|
)
|
||||||
@@ -94,7 +95,7 @@ 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
|
||||||
else:
|
else:
|
||||||
status = STATUS_READY if spec["code"] else STATUS_PENDING
|
status = STATUS_READY if spec["code"] else STATUS_PENDING
|
||||||
rows.append(
|
rows.append(
|
||||||
@@ -106,6 +107,8 @@ def ancillary_rows(counts: dict[str, Any] | None = None) -> list[dict[str, Any]]
|
|||||||
"status": status,
|
"status": status,
|
||||||
"work_item_code": spec["code"],
|
"work_item_code": spec["code"],
|
||||||
"legal_required": bool(spec["legal"]),
|
"legal_required": bool(spec["legal"]),
|
||||||
|
# 품셈에 공종이 없는 줄 — 금액은 별도 단가(사람 입력)로만 서서 인계는 늘 입력 갈래.
|
||||||
|
**({} if spec["code"] else {"blocked_kind": "input_missing"}),
|
||||||
"reason": " · ".join(reasons),
|
"reason": " · ".join(reasons),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,6 +8,12 @@
|
|||||||
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 = "값을 낼 근거가 없음"
|
||||||
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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -414,11 +414,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(
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|
||||||
|
|||||||
@@ -12,11 +12,8 @@ from __future__ import annotations
|
|||||||
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,
|
||||||
@@ -112,6 +109,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 +117,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):
|
||||||
@@ -613,71 +623,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",
|
||||||
|
|||||||
@@ -55,6 +55,21 @@ KNOWN_GAPS: dict[str, tuple[str, str]] = {
|
|||||||
"(제안 무한궤도 — 영월 실무 「06M3 B/H」 · 2026-09-14 브레인 ②).",
|
"(제안 무한궤도 — 영월 실무 「06M3 B/H」 · 2026-09-14 브레인 ②).",
|
||||||
),
|
),
|
||||||
# 2026-09-14 ㉮ — 사용횟수 갈래로 푼 뒤 남는 원문 몫. 값을 짓지 않고 말만.
|
# 2026-09-14 ㉮ — 사용횟수 갈래로 푼 뒤 남는 원문 몫. 값을 짓지 않고 말만.
|
||||||
|
# 봉상후렉시블 셋의 표 나머지 줄은 판정표 자동 목록(`_unread_rows`)이 맡음 — 줄이 아닌 [주] 만 여기.
|
||||||
|
"FP-12-12": ("원문 [주]", "ⓘ [주] 「성토부 날개벽 설치시 인건비 30% 할증」 은 안 걺(선택)."),
|
||||||
|
# 원문 대 실무 어긋남 기록(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": (
|
"FP-12-04": (
|
||||||
"원문 [주]",
|
"원문 [주]",
|
||||||
"ⓘ 「사용고재 평가기준 23%(합판과 각재의 설계단가 기준)」 은 원문이 셈을 안 줘 값으로 안 씀"
|
"ⓘ 「사용고재 평가기준 23%(합판과 각재의 설계단가 기준)」 은 원문이 셈을 안 줘 값으로 안 씀"
|
||||||
|
|||||||
@@ -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 = (
|
||||||
"품 할인·할증은 품(인력) 줄에 붙습니다 — 물량에 곱하면 자재·기계까지 부풀어"
|
"품 할인·할증은 품(인력) 줄에 붙습니다 — 물량에 곱하면 자재·기계까지 부풀어"
|
||||||
|
|||||||
@@ -63,7 +63,82 @@ JUDGED_TABLES: dict[str, dict[str, Any]] = {
|
|||||||
"needs_machine": {"구체콘크리트": "다짐:봉상후렉시블(45mm)"},
|
"needs_machine": {"구체콘크리트": "다짐:봉상후렉시블(45mm)"},
|
||||||
"why": "원문 L6460 「구체·버림콘크리트 ㎥ — 비고 칸 콘크리트공·보통인부 인/㎥」",
|
"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)」",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
#: 줄 첫 칸이 분류 딱지인 표(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인/㎥」.
|
#: 비고 칸 인력 — 「콘크리트공0.24인/㎥, 보통인부 0.42인/㎥」.
|
||||||
_RE_REMARK_LABOR = re.compile(r"([가-힣]+)\s*(\d+(?:\.\d+)?)\s*인/㎥")
|
_RE_REMARK_LABOR = re.compile(r"([가-힣]+)\s*(\d+(?:\.\d+)?)\s*인/㎥")
|
||||||
_RE_Q = re.compile(r"Q\s*=\s*(\d+(?:\.\d+)?)")
|
_RE_Q = re.compile(r"Q\s*=\s*(\d+(?:\.\d+)?)")
|
||||||
@@ -122,7 +197,8 @@ 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)
|
||||||
@@ -130,10 +206,13 @@ def match_judged_table(
|
|||||||
staged = _use_count(code, table, rows, catalog, basis_quantity, unit)
|
staged = _use_count(code, table, rows, catalog, basis_quantity, unit)
|
||||||
elif judged["shape"] == "remark_labor":
|
elif judged["shape"] == "remark_labor":
|
||||||
staged = _remark_labor(code, table, judged, rows, catalog)
|
staged = _remark_labor(code, table, judged, rows, catalog)
|
||||||
|
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)
|
||||||
@@ -240,7 +319,7 @@ def _remark_labor(code, table, judged, rows, catalog) -> list | str:
|
|||||||
by_name = {"".join(cells[0].split()): cells for cells in rows if cells}
|
by_name = {"".join(cells[0].split()): cells for cells in rows if cells}
|
||||||
staged: list = []
|
staged: list = []
|
||||||
for index, cells in enumerate(rows):
|
for index, cells in enumerate(rows):
|
||||||
labors = _RE_REMARK_LABOR.findall(cells[-1] if cells else "")
|
labors = _RE_REMARK_LABOR.findall(" ".join(cells[3:])) # 비고가 끝 칸이 아닌 표(12-16)
|
||||||
if len(cells) < 3 or cells[2] != "㎥" or not labors:
|
if len(cells) < 3 or cells[2] != "㎥" or not labors:
|
||||||
continue
|
continue
|
||||||
variant = cells[0].split("(")[0].strip()
|
variant = cells[0].split("(")[0].strip()
|
||||||
@@ -268,3 +347,26 @@ def _remark_labor(code, table, judged, rows, catalog) -> list | str:
|
|||||||
for entry, amount in pieces:
|
for entry, amount in pieces:
|
||||||
staged.append(_row(code, table, entry, amount, "㎥", index, variant))
|
staged.append(_row(code, table, entry, amount, "㎥", index, variant))
|
||||||
return staged
|
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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -899,9 +899,13 @@ def build_unit_prices(
|
|||||||
# 예외 = 사용자가 다른 품셈 밑수를 확정한 자리(9-21 「1,000㎡당」 · ÷ 는 ChooseOne 이 검) —
|
# 예외 = 사용자가 다른 품셈 밑수를 확정한 자리(9-21 「1,000㎡당」 · ÷ 는 ChooseOne 이 검) —
|
||||||
# 마스터 목록은 원문 사실이라 그대로 두고 여기서만 걷음(2026-09-14 브레인 · 사유는 비고).
|
# 마스터 목록은 원문 사실이라 그대로 두고 여기서만 걷음(2026-09-14 브레인 · 사유는 비고).
|
||||||
borrowed = work_item_code.startswith(tuple(BORROWED_BASIS_PER))
|
borrowed = work_item_code.startswith(tuple(BORROWED_BASIS_PER))
|
||||||
|
from B09_Estimation.B09_Estimation_ResourceAxis_JudgedTable import JUDGED_TABLES
|
||||||
|
|
||||||
for row in rows:
|
for row in rows:
|
||||||
section = missing_basis.get(str(row.pum_table_id))
|
section = missing_basis.get(str(row.pum_table_id))
|
||||||
if section and not borrowed:
|
# 비고가 「인/㎥」 로 밑수를 적은 판정표(12-12 날개벽 「개소당」 머리 없음)는 밑수가 ㎥ 로 섬.
|
||||||
|
per_remark = JUDGED_TABLES.get(str(row.pum_table_id), {}).get("shape") == "remark_labor"
|
||||||
|
if section and not borrowed and not per_remark:
|
||||||
build.basis_missing[work_item_code] = section
|
build.basis_missing[work_item_code] = section
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -1091,6 +1095,10 @@ def build_unit_prices(
|
|||||||
from B09_Estimation.B09_Estimation_Explosives import attach_explosives
|
from B09_Estimation.B09_Estimation_Explosives import attach_explosives
|
||||||
|
|
||||||
attach_explosives(build, nodes_by_code)
|
attach_explosives(build, nodes_by_code)
|
||||||
|
# 표토제거 답외구간(9-15-2) — 계수표를 불도저 식 + T 로(2026-09-14 ㉰).
|
||||||
|
from B09_Estimation.B09_Estimation_TopsoilRemoval import attach_topsoil_removal
|
||||||
|
|
||||||
|
attach_topsoil_removal(build, nodes_by_code)
|
||||||
# ⚠ **마지막에 한 번** — 조합 사용 공종의 본체 기계를 잡재료 16% 층으로 바꿔 단다
|
# ⚠ **마지막에 한 번** — 조합 사용 공종의 본체 기계를 잡재료 16% 층으로 바꿔 단다
|
||||||
# (품셈 제8장 [주]⑤). 조립 도중에 바꾸면 어느 공종이 조합인지 아직 모른다.
|
# (품셈 제8장 [주]⑤). 조립 도중에 바꾸면 어느 공종이 조합인지 아직 모른다.
|
||||||
build.combined_swapped = _apply_combined_misc_rate(
|
build.combined_swapped = _apply_combined_misc_rate(
|
||||||
|
|||||||
@@ -220,8 +220,10 @@ export interface HaulPlan {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이보다 작은 진동은 블록으로 세지 않는다. 측점 하나짜리 요철까지 블록을 만들면 balloon이
|
* **그림에서만** 이보다 작은 진동은 블록으로 세지 않는다. 측점 하나짜리 요철까지 블록을 만들면
|
||||||
* 수십 개 깔려 도면을 못 읽는다. 곡선 진폭 대비 비율이라 노선 규모에 자동으로 맞는다.
|
* balloon이 수십 개 깔려 도면을 못 읽는다. 곡선 진폭 대비 비율이라 노선 규모에 자동으로 맞는다.
|
||||||
|
* ⚠ 수량 계획에는 걸지 않는다 — 진폭이 커지면 작은 봉우리가 통째로 지워져 운반량이 0 이 됐다
|
||||||
|
* (2026-09-14 936be972 실측 474.14㎥ → 0 · 브레인 ①).
|
||||||
*/
|
*/
|
||||||
const MIN_SWING_RATIO = 0.02;
|
const MIN_SWING_RATIO = 0.02;
|
||||||
|
|
||||||
@@ -603,14 +605,17 @@ export function computeHaulPlan(
|
|||||||
/** 토량환산계수 — 구조물 잔토(자연상태)를 이 곡선의 **다짐상태**로 옮길 때만 쓴다.
|
/** 토량환산계수 — 구조물 잔토(자연상태)를 이 곡선의 **다짐상태**로 옮길 때만 쓴다.
|
||||||
* 안 오면 환산 없이 담긴다(값을 지어내지 않는다). */
|
* 안 오면 환산 없이 담긴다(값을 지어내지 않는다). */
|
||||||
conversion?: EarthworkConversion | null;
|
conversion?: EarthworkConversion | null;
|
||||||
|
/** 그림용 — 잔진동을 거른다(`MIN_SWING_RATIO`). 안 오면 **거르지 않는다**(수량 계획). */
|
||||||
|
drawing?: boolean;
|
||||||
},
|
},
|
||||||
): HaulPlan | null {
|
): HaulPlan | null {
|
||||||
const points = result.points;
|
const points = result.points;
|
||||||
if (points.length < 2) return null;
|
if (points.length < 2) return null;
|
||||||
|
|
||||||
const range = Math.max(result.max_cumulative_m3 - result.min_cumulative_m3, 0);
|
const range = Math.max(result.max_cumulative_m3 - result.min_cumulative_m3, 0);
|
||||||
const minSwing = Math.max(range * MIN_SWING_RATIO, 1);
|
const extrema = options?.drawing
|
||||||
const extrema = pruneExtrema(points, extremaIndices(points), minSwing);
|
? pruneExtrema(points, extremaIndices(points), Math.max(range * MIN_SWING_RATIO, 1))
|
||||||
|
: extremaIndices(points);
|
||||||
const tiers = sortedLimits(limits);
|
const tiers = sortedLimits(limits);
|
||||||
|
|
||||||
const blocks: HaulBlock[] = [];
|
const blocks: HaulBlock[] = [];
|
||||||
|
|||||||
@@ -51,6 +51,38 @@
|
|||||||
"pum_edition": "2026-01-01",
|
"pum_edition": "2026-01-01",
|
||||||
"basis": "12-15 집수정 표(원문 L6460)는 「봉상후렉시블(45mm) Q=5.4㎥/hr」 만 적고 전기·엔진을 안 적음 — 건설품셈 8-3 (4611) 은 전기식 플렉시블형 ø45(0.75㎾)·엔진식 플렉시블형 ø45(2.6㎾) 둘 · 12-34-1 「콘크리트 진동기(3.5HP)」 = 2.6㎾ 엔진식 · 운전경비표(8-4)에 엔진식 4611-0350(휘발유 1.0L)만 있음 → 엔진식. 전기식이 필요하면 그때 엶. 2026-09-14 브레인 승인(661 뒤 ①)"
|
"basis": "12-15 집수정 표(원문 L6460)는 「봉상후렉시블(45mm) Q=5.4㎥/hr」 만 적고 전기·엔진을 안 적음 — 건설품셈 8-3 (4611) 은 전기식 플렉시블형 ø45(0.75㎾)·엔진식 플렉시블형 ø45(2.6㎾) 둘 · 12-34-1 「콘크리트 진동기(3.5HP)」 = 2.6㎾ 엔진식 · 운전경비표(8-4)에 엔진식 4611-0350(휘발유 1.0L)만 있음 → 엔진식. 전기식이 필요하면 그때 엶. 2026-09-14 브레인 승인(661 뒤 ①)"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"axis": "resource",
|
||||||
|
"from": "다짐:봉상후렉시블(45mm)",
|
||||||
|
"to": "4611-0350",
|
||||||
|
"scope": "FP-12-12",
|
||||||
|
"pum_edition": "2026-01-01",
|
||||||
|
"basis": "12-12 날개벽(원문 L6419) 표는 「봉상후렉시블(45mm) Q=5.4㎥/hr」 만 적고 전기·엔진을 안 적음 — 12-15 집수정과 같은 줄 · 같은 근거(건설품셈 8-3 (4611) 전기식·엔진식 둘 · 12-34-1 「콘크리트 진동기(3.5HP)」 = 2.6㎾ 엔진식 · 운전경비표에 엔진식 4611-0350 만) → 엔진식. 2026-09-14 브레인(672 다음 · 봉상후렉시블 셋)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"axis": "resource",
|
||||||
|
"from": "다짐:봉상후렉시블(45mm)",
|
||||||
|
"to": "4611-0350",
|
||||||
|
"scope": "FP-12-13",
|
||||||
|
"pum_edition": "2026-01-01",
|
||||||
|
"basis": "12-13 면벽(원문 L6436) 표는 「봉상후렉시블(45mm) Q=5.4㎥/hr」 만 적고 전기·엔진을 안 적음 — 12-15 집수정과 같은 줄 · 같은 근거(건설품셈 8-3 (4611) 전기식·엔진식 둘 · 12-34-1 「콘크리트 진동기(3.5HP)」 = 2.6㎾ 엔진식 · 운전경비표에 엔진식 4611-0350 만) → 엔진식. 2026-09-14 브레인(672 다음 · 봉상후렉시블 셋)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"axis": "resource",
|
||||||
|
"from": "봉상후렉시블(45mm)",
|
||||||
|
"to": "4611-0350",
|
||||||
|
"scope": "FP-12-16",
|
||||||
|
"pum_edition": "2026-01-01",
|
||||||
|
"basis": "12-16 맨홀(원문 L6474) 표는 「봉상후렉시블(45mm) Q=5.4㎥/hr」 만 적고 전기·엔진을 안 적음 — 12-15 집수정과 같은 줄 · 같은 근거(건설품셈 8-3 (4611) 전기식·엔진식 둘 · 12-34-1 「콘크리트 진동기(3.5HP)」 = 2.6㎾ 엔진식 · 운전경비표에 엔진식 4611-0350 만) → 엔진식. 2026-09-14 브레인(672 다음 · 봉상후렉시블 셋)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"axis": "resource",
|
||||||
|
"from": "콘크리트 진동기(3.5HP)",
|
||||||
|
"to": "4611-0350",
|
||||||
|
"scope": "FP-12-34-01",
|
||||||
|
"pum_edition": "2026-01-01",
|
||||||
|
"basis": "12-34-1 「콘크리트 진동기(3.5HP)」 — 3.5HP = 2.6㎾ · 건설품셈 8-3 (4611) 엔진식 플렉시블형 ø45(2.6㎾) · 운전경비표에 엔진식만 · 실무 영월 중기목록 「콘크리트 진동기 45φ(2.6㎾)엔진식플렉시블형」. 2026-09-14 브레인"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"axis": "variant",
|
"axis": "variant",
|
||||||
"from": "0.2·소림",
|
"from": "0.2·소림",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"schema_version": "1.0",
|
"schema_version": "1.0",
|
||||||
"dataset_id": "data_work_item_master_manifest",
|
"dataset_id": "data_work_item_master_manifest",
|
||||||
"generated_at": "2026-09-14T22:25:14+09:00",
|
"generated_at": "2026-09-14T23:14:39+09:00",
|
||||||
"built_by": "B08_Quantity/B08_Quantity_Build_WorkItemMaster.py",
|
"built_by": "B08_Quantity/B08_Quantity_Build_WorkItemMaster.py",
|
||||||
"source": {
|
"source": {
|
||||||
"dataset_id": "pum_forest",
|
"dataset_id": "pum_forest",
|
||||||
@@ -12,8 +12,8 @@
|
|||||||
"files": [
|
"files": [
|
||||||
{
|
{
|
||||||
"file": "work_item_master_2026-01-01.json",
|
"file": "work_item_master_2026-01-01.json",
|
||||||
"sha256": "2f0d8bfb3836597ee6f2fd755e2f2e172cb33df1e1e9061af81982a43497f9b2",
|
"sha256": "708a6a80d016c42d3402bd2a6cb5266fa18e1e2b5301bac823e6ff79d17fb2b6",
|
||||||
"size_bytes": 838655
|
"size_bytes": 838969
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "form_undetermined_2026-01-01.json",
|
"file": "form_undetermined_2026-01-01.json",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"dataset_id": "work_item_master_forest",
|
"dataset_id": "work_item_master_forest",
|
||||||
"effective_date": "2026-01-01",
|
"effective_date": "2026-01-01",
|
||||||
"pum_edition": "2026-01-01",
|
"pum_edition": "2026-01-01",
|
||||||
"generated_at": "2026-09-14T22:25:14+09:00",
|
"generated_at": "2026-09-14T23:14:39+09:00",
|
||||||
"dataset_version": {
|
"dataset_version": {
|
||||||
"dataset_id": "pum_forest",
|
"dataset_id": "pum_forest",
|
||||||
"effective_date": "2026-01-01",
|
"effective_date": "2026-01-01",
|
||||||
@@ -32888,7 +32888,9 @@
|
|||||||
"formula_rows": [],
|
"formula_rows": [],
|
||||||
"special_glyphs": [],
|
"special_glyphs": [],
|
||||||
"capacity_formula_here": false,
|
"capacity_formula_here": false,
|
||||||
"variant_key": [],
|
"variant_key": [
|
||||||
|
"콘크리트"
|
||||||
|
],
|
||||||
"condition_note": [
|
"condition_note": [
|
||||||
"구 분",
|
"구 분",
|
||||||
"규 격",
|
"규 격",
|
||||||
@@ -32970,7 +32972,9 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"variant_keys": []
|
"variant_keys": [
|
||||||
|
"콘크리트"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"work_item_code": "FP-12-13",
|
"work_item_code": "FP-12-13",
|
||||||
@@ -32997,7 +33001,9 @@
|
|||||||
"formula_rows": [],
|
"formula_rows": [],
|
||||||
"special_glyphs": [],
|
"special_glyphs": [],
|
||||||
"capacity_formula_here": false,
|
"capacity_formula_here": false,
|
||||||
"variant_key": [],
|
"variant_key": [
|
||||||
|
"콘크리트"
|
||||||
|
],
|
||||||
"condition_note": [
|
"condition_note": [
|
||||||
"구 분",
|
"구 분",
|
||||||
"규 격",
|
"규 격",
|
||||||
@@ -33065,7 +33071,9 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"variant_keys": []
|
"variant_keys": [
|
||||||
|
"콘크리트"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"work_item_code": "FP-12-14",
|
"work_item_code": "FP-12-14",
|
||||||
@@ -33242,7 +33250,10 @@
|
|||||||
"formula_rows": [],
|
"formula_rows": [],
|
||||||
"special_glyphs": [],
|
"special_glyphs": [],
|
||||||
"capacity_formula_here": false,
|
"capacity_formula_here": false,
|
||||||
"variant_key": [],
|
"variant_key": [
|
||||||
|
"구체콘크리트",
|
||||||
|
"버림콘크리트"
|
||||||
|
],
|
||||||
"condition_note": [
|
"condition_note": [
|
||||||
"구 분",
|
"구 분",
|
||||||
"단위",
|
"단위",
|
||||||
@@ -33349,7 +33360,10 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"variant_keys": []
|
"variant_keys": [
|
||||||
|
"구체콘크리트",
|
||||||
|
"버림콘크리트"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"work_item_code": "FP-12-17",
|
"work_item_code": "FP-12-17",
|
||||||
@@ -35258,8 +35272,8 @@
|
|||||||
"pum_table_id": "F0385",
|
"pum_table_id": "F0385",
|
||||||
"section": "12-34-1. 콘크리트 타설(철근 진동기포함)",
|
"section": "12-34-1. 콘크리트 타설(철근 진동기포함)",
|
||||||
"source_line": 6843,
|
"source_line": 6843,
|
||||||
"pum_form": "reference",
|
"pum_form": "requirement",
|
||||||
"form_basis": "'별도계상' — 값이 아니라 참조 지시",
|
"form_basis": "사람 판정 — 인력(인)·기계(대, Q=5.4㎥/hr) 소요량 표 — 「별도계상」 은 레미콘 자재 줄 비고일 뿐",
|
||||||
"basis_quantity": 1.0,
|
"basis_quantity": 1.0,
|
||||||
"basis_unit": "개소",
|
"basis_unit": "개소",
|
||||||
"basis_source": "본문",
|
"basis_source": "본문",
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"""성토사면 길이 5m 초과 경고 — 벽 선 쪽만 빼고(좌·우 갈라) 경고만 (2026-09-14 브레인 (나)).
|
||||||
|
|
||||||
|
① 5m 를 **넘는** 쪽만 — 5.00 은 아님 · 원지반을 못 만난 하한값(≥)도 5m 이하면 아님
|
||||||
|
② 벽이 선 쪽은 뺀다 — 배관 유입(상단측)·유출(반대측) 기슭막이, 집수정은 벽 아님
|
||||||
|
③ 한쪽에만 벽 → 반대쪽은 그대로 경고 · 독립 기슭막이 설치 측(좌/우/양쪽)
|
||||||
|
④ 세월교·BOX암거는 양쪽 측벽
|
||||||
|
⑤ 문구는 브레인 승인 그대로
|
||||||
|
|
||||||
|
TS 를 실제로 돌린다(파이썬 짝이 없는 화면 판정).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||||||
|
SOURCE = PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Cross_FillSlope_Warn.ts"
|
||||||
|
|
||||||
|
_RUNNER = """
|
||||||
|
import { readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { FILL_SLOPE_WARN_TEXT, fillSlopeWarnings } from "./B06_Section_UI_Cross_FillSlope_Warn.js";
|
||||||
|
|
||||||
|
const [inputPath, outputPath] = process.argv.slice(2);
|
||||||
|
const cases = JSON.parse(readFileSync(inputPath, "utf8"));
|
||||||
|
const warnings = fillSlopeWarnings(cases.map((c) => c.section), (section) =>
|
||||||
|
cases.find((c) => c.section.station_id === section.station_id).lengths,
|
||||||
|
);
|
||||||
|
writeFileSync(outputPath, JSON.stringify({
|
||||||
|
text: FILL_SLOPE_WARN_TEXT,
|
||||||
|
warnings: warnings.map((w) => [w.section.station_id, w.sides.map((s) => s.side)]),
|
||||||
|
}));
|
||||||
|
"""
|
||||||
|
|
||||||
|
LONG = {"lengthM": 7.0, "open": False}
|
||||||
|
BOTH = {"left": LONG, "right": LONG}
|
||||||
|
|
||||||
|
|
||||||
|
def _culvert(inlet: str = "기슭막이", outlet: str = "기슭막이", **extra: object) -> dict:
|
||||||
|
return {"inlet": {"structure": inlet}, "outlet": {"structure": outlet}, **extra}
|
||||||
|
|
||||||
|
|
||||||
|
CASES = [
|
||||||
|
{"section": {"station_id": "plain"}, "lengths": BOTH},
|
||||||
|
{
|
||||||
|
"section": {"station_id": "edge"},
|
||||||
|
"lengths": {
|
||||||
|
"left": {"lengthM": 5.0, "open": False},
|
||||||
|
"right": {"lengthM": 4.2, "open": True},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
# 상단측 좌 → 유입(좌) 기슭막이 · 유출(우) 기슭막이 — 양쪽 다 벽.
|
||||||
|
{
|
||||||
|
"section": {"station_id": "pipe", "uphill_side": "left", "culvert": _culvert()},
|
||||||
|
"lengths": BOTH,
|
||||||
|
},
|
||||||
|
# 유입이 집수정 → 좌(유입측)는 벽 없음 → 좌만 경고.
|
||||||
|
{
|
||||||
|
"section": {"station_id": "basin", "uphill_side": "left", "culvert": _culvert("집수정")},
|
||||||
|
"lengths": BOTH,
|
||||||
|
},
|
||||||
|
# 상단측 우 → 유출은 좌 · 유출이 집수정이면 좌만 경고.
|
||||||
|
{
|
||||||
|
"section": {
|
||||||
|
"station_id": "right_up",
|
||||||
|
"uphill_side": "right",
|
||||||
|
"culvert": _culvert(outlet="집수정"),
|
||||||
|
},
|
||||||
|
"lengths": BOTH,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"section": {"station_id": "own_left", "culvert": _culvert(hidden_pipe=True, side="좌")},
|
||||||
|
"lengths": BOTH,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"section": {
|
||||||
|
"station_id": "revet_auto",
|
||||||
|
"revetment": {"side": None},
|
||||||
|
"design": {"section_mode": "left_cut"},
|
||||||
|
},
|
||||||
|
"lengths": {"left": None, "right": LONG},
|
||||||
|
},
|
||||||
|
{"section": {"station_id": "ford", "ford": {}}, "lengths": BOTH},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _run(tmp_path: Path) -> dict:
|
||||||
|
out = tmp_path / "js"
|
||||||
|
subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||||
|
[
|
||||||
|
"node",
|
||||||
|
str(TSC),
|
||||||
|
str(SOURCE),
|
||||||
|
# 실행에 드는 import 는 이것 하나 — 나머지는 타입 import 라 지워진다.
|
||||||
|
str(SOURCE.with_name("B06_Section_UI_Cross_Culvert_Const.ts")),
|
||||||
|
"--outDir",
|
||||||
|
str(out),
|
||||||
|
"--module",
|
||||||
|
"esnext",
|
||||||
|
"--target",
|
||||||
|
"es2022",
|
||||||
|
"--moduleResolution",
|
||||||
|
"bundler",
|
||||||
|
"--ignoreConfig",
|
||||||
|
# 타입 줄기가 별칭(@util 등)으로 번져 단독 컴파일로는 못 푼다 — 검사는 typecheck 몫.
|
||||||
|
"--noCheck",
|
||||||
|
"--noResolve",
|
||||||
|
],
|
||||||
|
cwd=str(PROJECT_ROOT),
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
for emitted in out.glob("*.js"):
|
||||||
|
text = emitted.read_text(encoding="utf-8")
|
||||||
|
emitted.write_text(
|
||||||
|
re.sub(r'(from "\./[^"]+?)(")', lambda m: m.group(1) + ".js" + m.group(2), text),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
|
||||||
|
payload, result = tmp_path / "input.json", tmp_path / "output.json"
|
||||||
|
payload.write_text(json.dumps(CASES, ensure_ascii=False), encoding="utf-8")
|
||||||
|
subprocess.run( # noqa: S603
|
||||||
|
["node", str(out / "runner.mjs"), str(payload), str(result)],
|
||||||
|
cwd=str(PROJECT_ROOT),
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
return json.loads(result.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||||||
|
def test_벽_선_쪽만_빼고_5m_넘는_쪽을_경고한다(tmp_path: Path) -> None:
|
||||||
|
got = dict(_run(tmp_path)["warnings"])
|
||||||
|
assert got == {
|
||||||
|
"plain": ["left", "right"],
|
||||||
|
"basin": ["left"],
|
||||||
|
"right_up": ["left"],
|
||||||
|
"own_left": ["right"],
|
||||||
|
}
|
||||||
|
# 5.00 · 하한값 4.2(≥) · 양쪽 벽 · 자동 설치 측 벽 · 세월교는 경고 없음.
|
||||||
|
assert not {"edge", "pipe", "revet_auto", "ford"} & set(got)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||||||
|
def test_문구는_승인된_그대로(tmp_path: Path) -> None:
|
||||||
|
assert _run(tmp_path)["text"] == (
|
||||||
|
"성토사면 길이 5m 초과 — 법령상 옹벽·석축 설치 대상 "
|
||||||
|
"(산림자원법 시행규칙 별표2 Ⅰ.2.차.(3).(나) · 임도설치 규정 별표7 2.차.(3).(나)) "
|
||||||
|
"※ 실무 표본에서도 흔함(영월 63% · 봉화 49%) — 설치 여부는 설계자 판단"
|
||||||
|
)
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""B07 횡단도 [확정]은 설계 **상태만** 올린다 — 정본 설계값을 덮지 않는다 (2026-09-14 브레인 ②).
|
||||||
|
|
||||||
|
실측(936be972 · 읽기만): 옛 [확정]은 입력 셋(지반·단면·측구 쪽)만으로 단면적을 다시 계산해
|
||||||
|
설계를 통째로 덮었다 — 62측점 전부 단면적이 바뀌고(절토 −20.8% · 성토 +5.8%) 암선·절토경사·
|
||||||
|
표준 횡단·구조물 트림이 빠졌으며, 종점 1078.01 은 **사용자가 끈 측구가 켜졌다**.
|
||||||
|
B07 CAD 에는 설계를 고치는 자리가 없으므로 덮을 값이 없다 — 「반만 계산할 거면 반만 덮는다」.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import B07_DesignDetail.B07_DesignDetail_Router as router
|
||||||
|
from B07_DesignDetail.B07_DesignDetail_Schema import DesignDrawingConfirmRequest
|
||||||
|
|
||||||
|
STORED = {
|
||||||
|
"status": "provisional",
|
||||||
|
"ground_type": "ripping_rock",
|
||||||
|
"section_mode": "left_cut",
|
||||||
|
"ditch_side": "left",
|
||||||
|
"ditch_enabled": False,
|
||||||
|
"rock_boundary_offset_m": 0.8,
|
||||||
|
"cut_slope_ratio": 0.5,
|
||||||
|
"cut_area_m2": 48.894,
|
||||||
|
"fill_area_m2": 54.84,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _Cursor:
|
||||||
|
async def __aenter__(self) -> _Cursor:
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_: object) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class _Connection:
|
||||||
|
async def begin(self) -> None: ...
|
||||||
|
|
||||||
|
async def commit(self) -> None: ...
|
||||||
|
|
||||||
|
async def rollback(self) -> None: ...
|
||||||
|
|
||||||
|
def cursor(self) -> _Cursor:
|
||||||
|
return _Cursor()
|
||||||
|
|
||||||
|
|
||||||
|
class _Acquire:
|
||||||
|
async def __aenter__(self) -> _Connection:
|
||||||
|
return _Connection()
|
||||||
|
|
||||||
|
async def __aexit__(self, *_: object) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def test_확정은_상태만_올리고_설계값을_안_덮는다(monkeypatch, tmp_path: Path) -> None:
|
||||||
|
patches: list[dict] = []
|
||||||
|
|
||||||
|
async def source(_project_id: UUID) -> tuple[int, Path, Path, bool]:
|
||||||
|
return 182, tmp_path, tmp_path / "longitudinal.json", False
|
||||||
|
|
||||||
|
async def designs(_route_id: int) -> dict[int, dict]:
|
||||||
|
return {700: dict(STORED)}
|
||||||
|
|
||||||
|
async def merge(_connection: object, **kwargs: object) -> bool:
|
||||||
|
patches.append(dict(kwargs["patch"])) # type: ignore[arg-type]
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def stage(*_: object) -> None: ...
|
||||||
|
|
||||||
|
monkeypatch.setattr(router, "_confirmed_source", source)
|
||||||
|
monkeypatch.setattr(router, "_designs_by_chainage", designs)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
router, "_drawing_list", lambda *_: [SimpleNamespace(id="cross_00700m", kind="cross")]
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(router, "extract_quantity_table", lambda *_: None)
|
||||||
|
monkeypatch.setattr(router, "_store_confirmed_drawing", lambda *_: False)
|
||||||
|
monkeypatch.setattr(router, "get_db_pool", lambda: SimpleNamespace(acquire=_Acquire))
|
||||||
|
monkeypatch.setattr(router, "merge_cross_section_design_by_round", merge)
|
||||||
|
monkeypatch.setattr(router, "start_stage", stage)
|
||||||
|
monkeypatch.setattr(router, "complete_stage", stage)
|
||||||
|
# 옛 길 — 입력 셋으로 다시 계산한 값(암 절토경사가 빠져 절토가 줄어든 모양)을 흉내 낸다.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
router,
|
||||||
|
"_recompute_confirmed_design",
|
||||||
|
lambda *_: {**STORED, "ditch_enabled": True, "cut_area_m2": 7.527, "status": "confirmed"},
|
||||||
|
raising=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = asyncio.run(
|
||||||
|
router.confirm_design_drawing(
|
||||||
|
UUID("936be972-11bc-46c2-8bf3-b15d8de7df0d"),
|
||||||
|
"cross_00700m",
|
||||||
|
DesignDrawingConfirmRequest(drawing={}),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 덮는 것은 상태 하나뿐 — 단면적·사용자 입력(측구 끔)은 그대로.
|
||||||
|
assert patches == [{"status": "confirmed"}]
|
||||||
|
# 화면 정보 패널이 받는 설계도 저장값 그대로(상태만 확정).
|
||||||
|
assert response.design == {**STORED, "status": "confirmed"}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""준비공 상태 칸 — 「입력이 필요함」과 「값을 낼 근거가 없음」을 가른다 (2026-09-14 브레인 ㉴).
|
||||||
|
|
||||||
|
훑기에서 잡은 거짓 사유: 상태 칸이 **입력만 넣으면 서는 줄**(표토 두께·거리 · 임목폐기물 조사값 ·
|
||||||
|
부대시설 개소 · 임목파쇄 부피)까지 「값을 낼 근거가 없음」으로 덮었다. 반대로 인계는
|
||||||
|
**우리가 만들어야 하는 줄**(사방 원단위 · 뿌리 부피)까지 「입력이 필요합니다」로 보냈다.
|
||||||
|
|
||||||
|
① 입력이면 풀리는 줄 → 상태 「입력이 필요함」 · 인계 `input_missing`
|
||||||
|
② 자료·산식이 없는 줄 → 상태 「값을 낼 근거가 없음」 · 인계 `unit_data_missing`
|
||||||
|
③ 앞 단계(횡단·사면표)가 안 선 줄도 사람이 할 일이라 ①
|
||||||
|
④ 규준틀이 필요 없는 노선(비탈길이 10m 이상 구간 없음)은 「해당 없음」
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff
|
||||||
|
from B08_Quantity.B08_Quantity_Engine_Preparation import build_table
|
||||||
|
from B08_Quantity.B08_Quantity_Engine_Preparation_Status import (
|
||||||
|
STATUS_NEEDS_INPUT,
|
||||||
|
STATUS_NOT_APPLICABLE,
|
||||||
|
STATUS_PENDING,
|
||||||
|
)
|
||||||
|
|
||||||
|
SLOPE = {"tree_removal_fill": 600.0, "tree_removal_cut": 400.0, "face_dressing_cut": 300.0}
|
||||||
|
SHORT_SLOPES = [{"lengths": {"fill": 4.0}, "distance_m": 20.0, "fill_height_m": 2.0}]
|
||||||
|
|
||||||
|
|
||||||
|
def _table(**overrides: object) -> dict:
|
||||||
|
args: dict = {
|
||||||
|
"slope_totals": SLOPE,
|
||||||
|
"structures": [{"type_id": "erosion_check"}],
|
||||||
|
"slope_rows": SHORT_SLOPES,
|
||||||
|
"road_surface_area_m2": 1000.0,
|
||||||
|
"chipping_enabled": True,
|
||||||
|
"tree_waste": {},
|
||||||
|
"ancillary_counts": {},
|
||||||
|
}
|
||||||
|
args.update(overrides)
|
||||||
|
return build_table(**args)
|
||||||
|
|
||||||
|
|
||||||
|
def _by_item(table: dict) -> dict[str, dict]:
|
||||||
|
return {row["item"]: row for row in table["rows"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_입력이면_서는_줄은_입력이_필요함() -> None:
|
||||||
|
rows = _by_item(_table())
|
||||||
|
for item in (
|
||||||
|
"표토 운반·적치",
|
||||||
|
"임목파쇄",
|
||||||
|
"임목폐기물 처리",
|
||||||
|
"국가지점번호판",
|
||||||
|
"가설창고(컨테이너)",
|
||||||
|
):
|
||||||
|
assert rows[item]["status"] == STATUS_NEEDS_INPUT, item
|
||||||
|
# 두께를 넣으면 거리 칸이 남는다 — 여전히 입력이 필요함.
|
||||||
|
with_thickness = _by_item(_table(topsoil_thickness_m=0.2))
|
||||||
|
assert with_thickness["표토 운반·적치"]["status"] == STATUS_NEEDS_INPUT
|
||||||
|
|
||||||
|
|
||||||
|
def test_자료나_산식이_없는_줄은_근거가_없음() -> None:
|
||||||
|
rows = _by_item(_table())
|
||||||
|
assert rows["뿌리 운반"]["status"] == STATUS_PENDING
|
||||||
|
assert (
|
||||||
|
rows[next(k for k, r in rows.items() if r["group"] == "사방공")]["status"] == STATUS_PENDING
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_앞_단계가_안_섰으면_입력이_필요함() -> None:
|
||||||
|
rows = _by_item(_table(slope_totals={}, slope_rows=[], road_surface_area_m2=None))
|
||||||
|
assert rows["표토제거"]["status"] == STATUS_NEEDS_INPUT
|
||||||
|
assert rows["뿌리 적재"]["status"] == STATUS_NEEDS_INPUT
|
||||||
|
assert rows["비탈 규준틀"]["status"] == STATUS_NEEDS_INPUT
|
||||||
|
assert rows["수평 규준틀"]["status"] == STATUS_NEEDS_INPUT
|
||||||
|
|
||||||
|
|
||||||
|
def test_규준틀이_필요_없는_노선은_해당_없음() -> None:
|
||||||
|
assert _by_item(_table())["비탈 규준틀"]["status"] == STATUS_NOT_APPLICABLE
|
||||||
|
|
||||||
|
|
||||||
|
def test_인계_막힘_갈래가_상태를_따른다() -> None:
|
||||||
|
table = _table()
|
||||||
|
handed = {row["name"]: row for row in build_handoff(preparation_table=table)["work_items"]}
|
||||||
|
assert handed["표토 운반·적치"]["blocked_kind"] == "input_missing"
|
||||||
|
assert handed["임목파쇄"]["blocked_kind"] == "input_missing"
|
||||||
|
# 우리가 만들어야 하는 줄 — 「입력이 필요합니다」로 보내면 사용자가 헛걸음한다.
|
||||||
|
assert handed["뿌리 운반"]["blocked_kind"] == "unit_data_missing"
|
||||||
|
assert handed["골막이"]["blocked_kind"] == "unit_data_missing"
|
||||||
|
# 해당 없음은 막힘이 아니다.
|
||||||
|
assert handed["비탈 규준틀"]["blocked_kind"] is None
|
||||||
|
# 공종이 품셈에 없는 부대시설은 개소를 넣어도 종전대로 「입력」(별도 단가) 갈래.
|
||||||
|
entered = _table(ancillary_counts={"national_point_sign": 3})
|
||||||
|
sign = next(
|
||||||
|
row
|
||||||
|
for row in build_handoff(preparation_table=entered)["work_items"]
|
||||||
|
if row["name"] == "국가지점번호판"
|
||||||
|
)
|
||||||
|
assert sign["blocked_kind"] == "input_missing"
|
||||||
|
|
||||||
|
|
||||||
|
def test_표_머리가_두_갈래를_따로_센다() -> None:
|
||||||
|
table = _table()
|
||||||
|
rows = table["rows"]
|
||||||
|
assert table["input_count"] == sum(1 for r in rows if r["status"] == STATUS_NEEDS_INPUT)
|
||||||
|
assert table["pending_count"] == sum(1 for r in rows if r["status"] == STATUS_PENDING)
|
||||||
|
assert table["input_count"] > 0 and table["pending_count"] > 0
|
||||||
@@ -23,7 +23,7 @@ sys.path.insert(0, str(ROOT))
|
|||||||
|
|
||||||
from B08_Quantity.B08_Quantity_Engine_Preparation import ( # noqa: E402
|
from B08_Quantity.B08_Quantity_Engine_Preparation import ( # noqa: E402
|
||||||
STATUS_COUNTED_ELSEWHERE,
|
STATUS_COUNTED_ELSEWHERE,
|
||||||
STATUS_PENDING,
|
STATUS_NEEDS_INPUT,
|
||||||
STATUS_READY,
|
STATUS_READY,
|
||||||
build_table,
|
build_table,
|
||||||
preparation_rows,
|
preparation_rows,
|
||||||
@@ -61,7 +61,8 @@ def test_20m_이내면_제거_품에_들어_따로_안_셈() -> None:
|
|||||||
def test_거리를_안_넣으면_막고_사유() -> None:
|
def test_거리를_안_넣으면_막고_사유() -> None:
|
||||||
"""⚠ 「최고 홍수위보다 높은 장소」는 현장이 정한다 — 품셈이 거리를 주지 않는다."""
|
"""⚠ 「최고 홍수위보다 높은 장소」는 현장이 정한다 — 품셈이 거리를 주지 않는다."""
|
||||||
row = 줄(0.2, None)["표토 운반·적치"]
|
row = 줄(0.2, None)["표토 운반·적치"]
|
||||||
assert row["amount"] is None and row["status"] == STATUS_PENDING
|
# 거리만 넣으면 서는 줄 — 「근거 없음」이 아니라 「입력이 필요함」(2026-09-14 ㉴).
|
||||||
|
assert row["amount"] is None and row["status"] == STATUS_NEEDS_INPUT
|
||||||
assert "운반거리가 아직 입력되지 않았습니다" in row["reason"]
|
assert "운반거리가 아직 입력되지 않았습니다" in row["reason"]
|
||||||
assert row["reference_amount"] == 200.0 # 값을 버리지 않는다
|
assert row["reference_amount"] == 200.0 # 값을 버리지 않는다
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ ROOT = Path(__file__).resolve().parents[2]
|
|||||||
sys.path.insert(0, str(ROOT))
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
from B08_Quantity.B08_Quantity_Engine_Preparation import ( # noqa: E402
|
from B08_Quantity.B08_Quantity_Engine_Preparation import ( # noqa: E402
|
||||||
STATUS_PENDING,
|
STATUS_NEEDS_INPUT,
|
||||||
STATUS_READY,
|
STATUS_READY,
|
||||||
build_table,
|
build_table,
|
||||||
)
|
)
|
||||||
@@ -43,7 +43,8 @@ def test_켜면_줄이_선다() -> None:
|
|||||||
|
|
||||||
def test_켜도_부피는_지어내지_않는다() -> None:
|
def test_켜도_부피는_지어내지_않는다() -> None:
|
||||||
row = 표(True)["임목파쇄"]
|
row = 표(True)["임목파쇄"]
|
||||||
assert row["amount"] is None and row["status"] == STATUS_PENDING
|
# 부피를 넣으면 서는 줄 — 「입력이 필요함」(2026-09-14 ㉴).
|
||||||
|
assert row["amount"] is None and row["status"] == STATUS_NEEDS_INPUT
|
||||||
assert "부피" in row["reason"]
|
assert "부피" in row["reason"]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""조립(묶음) 줄이 조각의 「일부만」·「밑수 없음」 을 올림 — 2026-09-14 브레인 ㉱ 첫째(구조 결함).
|
||||||
|
|
||||||
|
종전 `_composite_row` 는 조각 제목이 **있기만 하면** 금액에 더했음 → 조각 단가가 일부 몫만 섰거나(인력만)
|
||||||
|
밑수를 모르는 표여도 조립 줄이 **온전한 금액처럼** 섰음. 보통 줄은 그 둘을 막는데 조립 줄만 새던 자리.
|
||||||
|
⇒ 조각마다 보통 줄과 같은 두 검사 — 걸리면 금액을 안 세우고 어느 조각이 왜인지 사유.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill
|
||||||
|
from B09_Estimation.B09_Estimation_UnitPrice import cached_build
|
||||||
|
|
||||||
|
|
||||||
|
def _composite(parts: list[dict]) -> dict:
|
||||||
|
return {
|
||||||
|
"work_items": [
|
||||||
|
{
|
||||||
|
"work_item_code": None,
|
||||||
|
"name": "시험 묶음",
|
||||||
|
"spec": "",
|
||||||
|
"unit": "개소",
|
||||||
|
"quantity": "1",
|
||||||
|
"in_bill": True,
|
||||||
|
"composite_parts": parts,
|
||||||
|
"composite_not_ready": [],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"materials": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _line(parts: list[dict]):
|
||||||
|
rows = build_bill(_composite(parts)).rows
|
||||||
|
return next(r for r in rows if r.code is None and r.name == "시험 묶음")
|
||||||
|
|
||||||
|
|
||||||
|
def test_일부만_선_조각이_있으면_묶음_금액을_안_세우고_까닭() -> None:
|
||||||
|
build = cached_build()
|
||||||
|
partial = next(c for c in build.partial_ratio if f"B-{c}" in build.book.titles)
|
||||||
|
line = _line([{"code": partial, "quantity": 1}])
|
||||||
|
assert line.amount_krw is None, (partial, line.amount_krw)
|
||||||
|
assert partial in line.note and "일부만" in line.note, line.note
|
||||||
|
|
||||||
|
|
||||||
|
def test_밑수_없는_조각도_같이_막음() -> None:
|
||||||
|
build = cached_build()
|
||||||
|
missing = next(c for c in build.basis_missing if f"B-{c}" in build.book.titles)
|
||||||
|
line = _line([{"code": missing, "quantity": 1}])
|
||||||
|
assert line.amount_krw is None and "밑수" in line.note, (missing, line.note)
|
||||||
|
|
||||||
|
|
||||||
|
def test_온전한_조각만이면_종전대로_금액() -> None:
|
||||||
|
line = _line([{"code": "FP-09-15-02", "quantity": 2}])
|
||||||
|
assert line.amount_krw and line.amount_krw > 0, line.note
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""봉상후렉시블 셋 + 12-34-1 — 2026-09-14 브레인(672 다음).
|
||||||
|
|
||||||
|
같은 「봉상후렉시블(45mm) Q=5.4㎥/hr」 줄 하나가 12-12 날개벽 · 12-13 면벽 · 12-16 맨홀을 막고 있었음.
|
||||||
|
세 표는 12-15 집수정과 같은 모양(㎥ 줄 비고 인력 + 다짐기 줄) — 판정표 한 벌에 더하고 엔진식 4611-0350 로 이음.
|
||||||
|
실무 영월 「콘크리트타설(임도품셈 5-5)」 호표가 같은 모양: 콘크리트공 0.15·보통인부 0.27 인/㎥ + 진동기 1/Q(5.4)
|
||||||
|
· 진동기 45φ(2.6㎾)엔진식플렉시블형 손료 252천원 × 0.5101 · 노무 0 — 엔진식·계수·조종원 없음이 실무 실증.
|
||||||
|
|
||||||
|
12-34-1 콘크리트 타설(철근 진동기 포함) — 실무 호표 없음 → 브레인 판정
|
||||||
|
① 「콘크리트 진동기(3.5HP) 대 2」 만 4611-0350 × 2대 · 「봉상후렉시블(45mm) 대 2」 는 같은 기계의 봉이라 안 셈
|
||||||
|
② 머리 「(단위: 개소당)」 ↔ 인력 0.17·0.29 가 12-16 맨홀 ㎥당과 같고 기계도 Q ㎥/hr ⇒ ㎥당으로 읽고 사유에 나란히
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from B09_Estimation.B09_Estimation_UnitPrice import cached_build, detail_of
|
||||||
|
|
||||||
|
VIBRATOR = "X-4611-0350"
|
||||||
|
PER_Q = Decimal(1) / Decimal("5.4")
|
||||||
|
|
||||||
|
|
||||||
|
def _rows(build, code: str) -> dict[str, Decimal]:
|
||||||
|
return {
|
||||||
|
r["ref_code"]: Decimal(r["quantity"])
|
||||||
|
for r in detail_of(build, code)["rows"]
|
||||||
|
if r.get("ref_code")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_날개벽_면벽은_콘크리트_갈래가_진동기와_함께_섬() -> None:
|
||||||
|
build = cached_build()
|
||||||
|
for code in ("B-FP-12-12#콘크리트", "B-FP-12-13#콘크리트"):
|
||||||
|
rows = _rows(build, code)
|
||||||
|
assert rows[VIBRATOR] == PER_Q and rows["1013"] == Decimal("0.15"), (code, rows)
|
||||||
|
assert rows["1002"] == Decimal("0.27"), (code, rows)
|
||||||
|
assert build.book.titles[code].unit == "㎥"
|
||||||
|
assert (
|
||||||
|
"FP-12-12" not in build.basis_missing
|
||||||
|
) # 비고가 인/㎥ — 「개소당」 머리가 없어도 밑수는 ㎥
|
||||||
|
|
||||||
|
|
||||||
|
def test_맨홀은_비고가_끝_칸이_아니어도_구체_버림_갈래() -> None:
|
||||||
|
build = cached_build()
|
||||||
|
body = _rows(build, "B-FP-12-16#구체콘크리트")
|
||||||
|
assert (
|
||||||
|
body[VIBRATOR] == PER_Q
|
||||||
|
and body["1013"] == Decimal("0.17")
|
||||||
|
and body["1002"] == Decimal("0.29")
|
||||||
|
)
|
||||||
|
lean = _rows(build, "B-FP-12-16#버림콘크리트")
|
||||||
|
assert lean == {"1013": Decimal("0.15"), "1002": Decimal("0.27")}, lean
|
||||||
|
for code in ("FP-12-12", "FP-12-13", "FP-12-16"):
|
||||||
|
assert "봉상후렉시블" not in " ".join(build.unattached.get(code, [])), code
|
||||||
|
|
||||||
|
|
||||||
|
def test_12_34_1_진동기_두_대만_세고_봉은_두_번_안_셈() -> None:
|
||||||
|
build = cached_build()
|
||||||
|
rows = _rows(build, "B-FP-12-34-01")
|
||||||
|
assert rows[VIBRATOR] == 2 * PER_Q, rows
|
||||||
|
assert rows["1013"] == Decimal("0.17") and rows["1002"] == Decimal("0.29"), rows
|
||||||
|
from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note
|
||||||
|
|
||||||
|
assert "봉상후렉시블" in known_gap_note("FP-12-34-01") and "두 번 안 셈" in known_gap_note(
|
||||||
|
"FP-12-34-01"
|
||||||
|
)
|
||||||
|
refs = [r.get("ref_code") for r in detail_of(build, "B-FP-12-34-01")["rows"]]
|
||||||
|
assert refs.count(VIBRATOR) == 1, refs # 봉 줄이 기계로 또 안 섬(한 줄 · 2대 몫)
|
||||||
|
|
||||||
|
|
||||||
|
def test_12_34_1_은_개소당_머리를_세제곱미터당으로_읽은_까닭을_보임() -> None:
|
||||||
|
from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note
|
||||||
|
|
||||||
|
build = cached_build()
|
||||||
|
assert build.book.titles["B-FP-12-34-01"].unit == "㎥"
|
||||||
|
note = known_gap_note("FP-12-34-01")
|
||||||
|
assert "개소당" in note and "㎥당" in note and "12-16" in note, note
|
||||||
|
|
||||||
|
|
||||||
|
def test_표의_나머지_줄은_조용히_안_사라지고_사유에_원문_그대로() -> None:
|
||||||
|
"""손 사유로 달던 줄 목록은 판정표 자동 목록(`_unread_rows`)으로 옮김 — `test_b09_judged_unread_rows`."""
|
||||||
|
left = " ".join(cached_build().unattached.get("FP-12-16", []))
|
||||||
|
assert "원형거푸집" in left and "설치비" in left, left
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""판정표가 안 읽은 줄 — 자동으로 「못 붙은 줄」 목록에 (2026-09-14 브레인 · ㉰ 앞 뿌리 고침).
|
||||||
|
|
||||||
|
판정표는 적어 둔 줄만 읽음 → 나머지 줄(거푸집·철근·뚜껑·설치비·비고 …)이 사유 없이 사라지고 있었음
|
||||||
|
(12-15 · 12-04 비고 · 12-34-1 레미콘). 표를 넣을 때마다 손으로 사유를 달면 다음 표가 또 샘.
|
||||||
|
⇒ 판정표 공통 한 곳에서 「읽힌 줄 밖의 줄」 을 올림.
|
||||||
|
㉠ 넘치지 않게 — 자원 머리(첫 줄 머리 모양) · 빈 줄 · 다짐기처럼 다른 줄이 쓴 줄은 뺌
|
||||||
|
㉡ 손 사유와 안 겹치게 — 공종 사유 한 줄(`known_gap_note`)이 이미 적은 이름은 안 올림
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note
|
||||||
|
from B09_Estimation.B09_Estimation_UnitPrice import cached_build
|
||||||
|
|
||||||
|
|
||||||
|
def _left(build, code: str) -> str:
|
||||||
|
return " / ".join(build.unattached.get(code, []))
|
||||||
|
|
||||||
|
|
||||||
|
def test_집수정_표의_거푸집_철근_뚜껑_설치비가_목록에_뜸() -> None:
|
||||||
|
left = _left(cached_build(), "FP-12-15")
|
||||||
|
for name in ("거 푸 집", "철근", "집수정 뚜껑", "설치비"):
|
||||||
|
assert name in left, (name, left)
|
||||||
|
assert "봉상후렉시블" not in left, left # 구체 갈래가 쓴 다짐기 줄은 안 올림(㉠)
|
||||||
|
|
||||||
|
|
||||||
|
def test_합판거푸집_비고는_글과_함께_뜨고_손_사유에_있는_사용고재는_안_겹침() -> None:
|
||||||
|
left = _left(cached_build(), "FP-12-04")
|
||||||
|
assert "비고" in left and "7m" in left, left
|
||||||
|
assert "사용고재" in known_gap_note("FP-12-04") and "사용고재" not in left, left # ㉡
|
||||||
|
assert "횟수별" not in left, left # 머리 줄(㉠)
|
||||||
|
|
||||||
|
|
||||||
|
def test_토사면_고르기_자원_머리_줄은_안_뜸() -> None:
|
||||||
|
left = _left(cached_build(), "FP-09-19-01")
|
||||||
|
assert "보통인부 (인)" not in left and "공기압축기 (시간)" not in left, left
|
||||||
|
|
||||||
|
|
||||||
|
def test_12_34_1_레미콘_별도계상_줄은_이름으로_뜸() -> None:
|
||||||
|
left = _left(cached_build(), "FP-12-34-01")
|
||||||
|
assert "콘크리트(레미콘)" in left and "자재" not in left.split(" / "), left
|
||||||
|
|
||||||
|
|
||||||
|
def test_날개벽_면벽_맨홀은_자동_목록이_맡고_손_사유는_줄_목록을_안_되풀이() -> None:
|
||||||
|
build = cached_build()
|
||||||
|
assert "원형거푸집" in _left(build, "FP-12-16") and "기초잡석" in _left(build, "FP-12-12")
|
||||||
|
assert "기초잡석" in _left(build, "FP-12-13")
|
||||||
|
for code in ("FP-12-12", "FP-12-13", "FP-12-16"):
|
||||||
|
assert "거푸집" not in known_gap_note(code), code
|
||||||
|
assert "30%" in known_gap_note("FP-12-12") # 줄이 아닌 [주] 는 손 사유로 남음
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""품 할증 겹침 — 2026-09-14 브레인 672: 건설 공통 1-4-2 「할증의 중복가산요령」.
|
||||||
|
|
||||||
|
원문: 「W = 기본품 × (1 + a1 + a2 + a3 + … + an) · 단, 동일성격의 품할증요소의 이중적용은 불가함」
|
||||||
|
산림품셈 1-4 에는 겹침 규정이 없어 **교차 참조**로 건설 규정을 따름 — 합산은 이제 「우리가 둔 것」이 아니라 원문.
|
||||||
|
「동일성격」이 어느 계열끼리인지는 원문이 안 정함 → 막지 않고 설계자에게 그 단서를 그대로 보임.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import B09_Estimation.B09_Estimation_LaborSurcharge as LS
|
||||||
|
|
||||||
|
|
||||||
|
def test_여럿을_고르면_합산이고_근거는_건설_1_4_2() -> None:
|
||||||
|
assert LS.COMBINE_RULE == "sum"
|
||||||
|
picks = LS.parse_choices({"1-4-1": "1-4-1:0", "1-4-5": "1-4-5:1"}) # 10% · 5%
|
||||||
|
assert LS.total_percent(picks)[0] == Decimal(15)
|
||||||
|
note = LS.COMBINE_NOTE
|
||||||
|
assert "1-4-2" in note and "1 + a1 + a2" in note and "교차 참조" in note, note
|
||||||
|
assert "확정 대기" not in note and "우리가 그렇게" not in note, note
|
||||||
|
|
||||||
|
|
||||||
|
def test_동일성격_이중적용_불가_단서를_보임() -> None:
|
||||||
|
assert "동일성격" in LS.COMBINE_NOTE and "이중적용" in LS.COMBINE_NOTE
|
||||||
|
assert "설계자" in LS.COMBINE_NOTE # 어느 계열이 동일성격인지는 원문이 안 정함
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""표토제거 답외구간 9-15-2 — 2026-09-14 브레인 ㉰ 첫째(B08 준비공 표토제거 줄이 부르는 코드).
|
||||||
|
|
||||||
|
원문 L5288: T 0.2m · L 20m · E 0.4 · q0 3.2㎥ · e 0.96 · f 1/1.3 · V1 40m/분(1단) · V2 46m/분(1단) · t 0.25분
|
||||||
|
[주]① 무한궤도 불도저(19ton) ② q = 3.2 × e · ㎝ = L/V1 + L/V2 + 0.25 · Q1 = 60 × q × f × E / ㎝ ㎥/시간 · Q = Q1/T ㎡/시간
|
||||||
|
③ 건설품셈 8-2-1 불도저 참조 → 불도저 식(`dozer_hourly_output`)을 그대로 쓰고 T 로 나눔
|
||||||
|
⚠ 실무 영월 「표토제거 답외구간」 호표 Q=576.95 는 **E 자리에 e(0.96)를 넣은 값**으로 정확히 역산됨
|
||||||
|
(60 × 3.07 × 0.77 × 0.96 ÷ (1.18 × 0.2) = 576.95) · 다른 실무(봉화·대흥·소광·거창)엔 이 호표 없음 →
|
||||||
|
그 현장 실수 · 원문이 또렷하므로 원문 E=0.4 로 셈(2026-09-15 브레인 규칙 · 어긋남 기록)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from B09_Estimation.B09_Estimation_UnitPrice import cached_build
|
||||||
|
|
||||||
|
|
||||||
|
def test_표토제거_답외구간_일위대가가_불도저_19톤_1_Q_로_섬() -> None:
|
||||||
|
from B09_Estimation.B09_Estimation_TopsoilRemoval import topsoil_output
|
||||||
|
|
||||||
|
q1, q = topsoil_output()
|
||||||
|
assert q1 == Decimal("47.92") and q == Decimal("239.60"), (q1, q)
|
||||||
|
book = cached_build().book
|
||||||
|
title = book.titles["B-FP-09-15-02"]
|
||||||
|
assert title.unit == "㎡"
|
||||||
|
rows = [
|
||||||
|
r for d in book.details["B-FP-09-15-02"] for r in [d, *book.details.get(d.ref_code, [])]
|
||||||
|
]
|
||||||
|
dozer = next(r for r in rows if r.ref_code == "X-0101-0019")
|
||||||
|
assert dozer.quantity == Decimal(1) / q and dozer.output == q, dozer
|
||||||
|
|
||||||
|
|
||||||
|
def test_식_줄에_T_로_나눈_것과_실무_어긋남이_적힘() -> None:
|
||||||
|
book = cached_build().book
|
||||||
|
notes = " ".join(
|
||||||
|
r.note
|
||||||
|
for d in book.details["B-FP-09-15-02"]
|
||||||
|
for r in [d, *book.details.get(d.ref_code, [])]
|
||||||
|
)
|
||||||
|
assert "T" in notes and "9-15-2" in notes, notes
|
||||||
|
from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note
|
||||||
|
|
||||||
|
note = known_gap_note("FP-09-15-02")
|
||||||
|
assert "576.95" in note and "0.96" in note and "E=0.4" in note, note
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
"""유토 배분 — **수량은 거르지 않은 계획**, 잔진동 거르기는 **그림에만** (2026-09-14 브레인 ①).
|
||||||
|
|
||||||
|
실측(936be972 · ㉳ 샘플 넓힘 뒤): 성토가 늘어 곡선 진폭이 커지자 「진폭 × 2%」 거르기가
|
||||||
|
작은 절토 봉우리를 통째로 지워 **운반량 474.14㎥ → 0** 이 됐다. 거르기는 balloon 이
|
||||||
|
수십 개 깔리는 것을 막으려고 둔 **도면용 손질**이라 수량에 닿으면 안 된다.
|
||||||
|
|
||||||
|
① `computeHaulPlan` 기본은 거르지 않는다 — 작은 봉우리도 블록(운반)으로 남는다.
|
||||||
|
② `drawing: true` 일 때만 거른다 — 그림은 종전대로 깔끔하다.
|
||||||
|
③ 서버 정본은 둘을 따로 둔다 — `haul_plan`(수량) · `haul_plan_drawing`(토적도·화면).
|
||||||
|
④ B07 토적도는 그림용을 먼저 읽는다(없으면 옛 저장분 `haul_plan`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import build_mass_haul_drawing
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||||||
|
|
||||||
|
_RUNNER = """
|
||||||
|
import { readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { computeHaulPlan } from "./common_util_mass_haul_balance.js";
|
||||||
|
|
||||||
|
const [inputPath, outputPath] = process.argv.slice(2);
|
||||||
|
const input = JSON.parse(readFileSync(inputPath, "utf8"));
|
||||||
|
const out = input.cases.map((options) => {
|
||||||
|
const plan = computeHaulPlan(input.result, null, options);
|
||||||
|
return plan && {
|
||||||
|
hauled_m3: plan.hauled_m3, borrow_m3: plan.borrow_m3, blocks: plan.blocks.length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
writeFileSync(outputPath, JSON.stringify(out));
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _bump_then_fill() -> dict:
|
||||||
|
"""앞에 작은 절토 봉우리(+10㎥) · 뒤에 큰 성토(−1,000㎥).
|
||||||
|
|
||||||
|
진폭 2% 는 20㎥ 라 그림용 거르기에서는 봉우리가 지워진다."""
|
||||||
|
nets = [0.0, 10.0, -10.0] + [-100.0] * 10
|
||||||
|
points, cumulative = [], 0.0
|
||||||
|
for index, net in enumerate(nets):
|
||||||
|
cumulative += net
|
||||||
|
points.append(
|
||||||
|
{
|
||||||
|
"station_id": f"S{index:03d}",
|
||||||
|
"chainage_m": index * 20.0,
|
||||||
|
"net_volume_m3": net,
|
||||||
|
"cumulative_volume_m3": cumulative,
|
||||||
|
"cut_soil_m3": max(net, 0.0),
|
||||||
|
"cut_rock_m3": 0.0,
|
||||||
|
"cut_rr_m3": 0.0,
|
||||||
|
"cut_br_m3": 0.0,
|
||||||
|
"cut_compacted_m3": max(net, 0.0),
|
||||||
|
"fill_m3": max(-net, 0.0),
|
||||||
|
"natural_spoil": False,
|
||||||
|
"net_area_m2": net / 20.0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"points": points,
|
||||||
|
"cut_natural_m3": {"ea": 10.0, "rr": 0.0, "br": 0.0},
|
||||||
|
"cut_compacted_m3": 10.0,
|
||||||
|
"fill_compacted_m3": 1010.0,
|
||||||
|
"final_cumulative_m3": cumulative,
|
||||||
|
"surplus_m3": 0.0,
|
||||||
|
"shortage_m3": -cumulative,
|
||||||
|
"min_cumulative_m3": cumulative,
|
||||||
|
"max_cumulative_m3": 10.0,
|
||||||
|
"conversion": {"soil": 1.0, "ripping_rock": 1.0, "blasting_rock": 1.0},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(tmp_path: Path, cases: list[dict]) -> list[dict | None]:
|
||||||
|
out = tmp_path / "js"
|
||||||
|
subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||||
|
[
|
||||||
|
"node",
|
||||||
|
str(TSC),
|
||||||
|
str(PROJECT_ROOT / "common_util" / "common_util_mass_haul_balance.ts"),
|
||||||
|
"--outDir",
|
||||||
|
str(out),
|
||||||
|
"--module",
|
||||||
|
"esnext",
|
||||||
|
"--target",
|
||||||
|
"es2022",
|
||||||
|
"--moduleResolution",
|
||||||
|
"bundler",
|
||||||
|
"--ignoreConfig",
|
||||||
|
],
|
||||||
|
cwd=str(PROJECT_ROOT),
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
for emitted in out.glob("*.js"):
|
||||||
|
text = emitted.read_text(encoding="utf-8")
|
||||||
|
emitted.write_text(
|
||||||
|
re.sub(r'(from "\./[^"]+?)(")', lambda m: m.group(1) + ".js" + m.group(2), text),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
|
||||||
|
payload = tmp_path / "input.json"
|
||||||
|
result = tmp_path / "output.json"
|
||||||
|
payload.write_text(json.dumps({"result": _bump_then_fill(), "cases": cases}), encoding="utf-8")
|
||||||
|
subprocess.run( # noqa: S603
|
||||||
|
["node", str(out / "runner.mjs"), str(payload), str(result)],
|
||||||
|
cwd=str(PROJECT_ROOT),
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
return json.loads(result.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||||||
|
def test_수량_계획은_작은_봉우리도_운반으로_센다(tmp_path: Path) -> None:
|
||||||
|
quantity, drawing = _run(tmp_path, [{}, {"drawing": True}])
|
||||||
|
assert quantity is not None and drawing is not None
|
||||||
|
# ① 기본(수량) — 봉우리 10㎥ 가 뒤 성토로 옮겨진다.
|
||||||
|
assert quantity["hauled_m3"] == pytest.approx(10.0)
|
||||||
|
# ② 그림 — 종전대로 걸러져 블록이 없다.
|
||||||
|
assert drawing["blocks"] == 0
|
||||||
|
assert drawing["hauled_m3"] == pytest.approx(0.0)
|
||||||
|
# 토취(순 부족분)는 거르기와 무관하게 같다 — 운반만 사라졌던 것이다.
|
||||||
|
assert quantity["borrow_m3"] == pytest.approx(drawing["borrow_m3"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_서버_정본은_수량과_그림을_따로_둔다() -> None:
|
||||||
|
source = (PROJECT_ROOT / "B06_Section" / "B06_Section_Server_Calc_Node.ts").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
# ③ 화면 선반입(그림)은 거른 계획, 저장 정본은 둘 다.
|
||||||
|
assert "drawing: true" in source
|
||||||
|
assert "haul_plan_drawing" in source
|
||||||
|
|
||||||
|
|
||||||
|
def test_토적도는_그림용_계획을_먼저_그린다() -> None:
|
||||||
|
def band(volume: float) -> dict:
|
||||||
|
return {
|
||||||
|
"index": 1,
|
||||||
|
"equipment": "free_haul",
|
||||||
|
"volume_m3": volume,
|
||||||
|
"haul_distance_m": 10.0,
|
||||||
|
"ea_m3": volume,
|
||||||
|
"rr_m3": 0.0,
|
||||||
|
"br_m3": 0.0,
|
||||||
|
"level_base_m3": 0.0,
|
||||||
|
"level_apex_m3": volume,
|
||||||
|
"boundary_from_m": 0.0,
|
||||||
|
"boundary_to_m": 40.0,
|
||||||
|
"haul_from_m": 10.0,
|
||||||
|
"haul_to_m": 30.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
def plan(volume: float) -> dict:
|
||||||
|
block = {
|
||||||
|
"index": 1,
|
||||||
|
"from_m": 0.0,
|
||||||
|
"to_m": 40.0,
|
||||||
|
"base_m3": 0.0,
|
||||||
|
"volume_m3": volume,
|
||||||
|
"direction": "forward",
|
||||||
|
"bands": [band(volume)],
|
||||||
|
}
|
||||||
|
return {"blocks": [block], "residuals": [], "transfers": []}
|
||||||
|
|
||||||
|
longitudinal = {"stations": [{"chainage_m": x, "station_id": f"s{x}"} for x in (0, 20, 40)]}
|
||||||
|
points = [
|
||||||
|
{"station_id": "s0", "chainage_m": 0.0, "cumulative_volume_m3": 0.0},
|
||||||
|
{"station_id": "s20", "chainage_m": 20.0, "cumulative_volume_m3": 700.0},
|
||||||
|
{"station_id": "s40", "chainage_m": 40.0, "cumulative_volume_m3": 0.0},
|
||||||
|
]
|
||||||
|
mass_haul = {"points": points, "haul_plan": plan(111.0), "haul_plan_drawing": plan(222.0)}
|
||||||
|
|
||||||
|
def labels(drawing: dict) -> list[str]:
|
||||||
|
out: list[str] = []
|
||||||
|
|
||||||
|
def walk(entity: dict) -> None:
|
||||||
|
if entity.get("type") == "Text":
|
||||||
|
out.append(entity["shapeData"]["label"])
|
||||||
|
for child in entity.get("children") or []:
|
||||||
|
walk(child)
|
||||||
|
|
||||||
|
for entity in drawing["entities"]:
|
||||||
|
walk(entity)
|
||||||
|
return out
|
||||||
|
|
||||||
|
drawn = labels(build_mass_haul_drawing(longitudinal, mass_haul, "mass_haul"))
|
||||||
|
assert "Q= 222.00M3" in drawn and "Q= 111.00M3" not in drawn
|
||||||
|
# 옛 저장분(그림용 없음)은 종전대로 `haul_plan` 을 그린다.
|
||||||
|
del mass_haul["haul_plan_drawing"]
|
||||||
|
legacy = labels(build_mass_haul_drawing(longitudinal, mass_haul, "mass_haul"))
|
||||||
|
assert "Q= 111.00M3" in legacy
|
||||||
Reference in New Issue
Block a user