Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1

This commit is contained in:
2026-10-08 20:24:15 +09:00
13 changed files with 415 additions and 66 deletions
+4
View File
@@ -270,6 +270,8 @@ export interface FordWingSpec {
/** 세월교 측점의 세트 제원 — 양측 ㄴ형 측벽 + 바닥판 + 관, 관 위는 성토 채움. */
export interface FordSet {
type: "ford";
/** 이 시설이 **놓인** 누가거리(m) — 걸친 옆 측점과 주인 측점을 가리는 열쇠(131). */
chainage_m?: number;
pipe_kind: string | null;
diameter_m: number;
/** 관 련수. 단면엔 1개만 그리고 라벨에만 쓴다. */
@@ -571,6 +573,8 @@ export interface CrossSectionPatch {
box_ground_slope_pct?: number;
/** B08 구조물 집계표 BOX 본체 길이(m · 120) — 박스 두 끝 offset 차 · 같은 길. */
box_length_m?: number;
/** B08 자재 집계표 세월교 관 한 련 길이(m · 131) — 그린 관 길이 1 m 올림 · 같은 길. */
ford_pipe_length_m?: number;
inlet_structure?: "auto" | "revet" | "I" | "L" | "U";
basin_adjust?: {
innerWidthM: number;
+2
View File
@@ -190,6 +190,8 @@ class CrossSectionPatch(BaseModel):
box_ground_slope_pct: float | None = None
# B08 구조물 집계표 BOX 본체 길이(120) — 박스 두 끝 offset 차(m) · 같은 길.
box_length_m: float | None = Field(default=None, ge=0)
# B08 자재 집계표 세월교 관 한 련 길이(131) — 그린 관 길이 1 m 올림(m) · 같은 길.
ford_pipe_length_m: float | None = Field(default=None, ge=0)
class SectionConfirmRequest(BaseModel):
@@ -69,6 +69,7 @@ _AREA_KEYS = (
"pipe_slope_pct",
"box_ground_slope_pct",
"box_length_m",
"ford_pipe_length_m",
)
@@ -30,6 +30,7 @@ import type {
RevetOffsetControl,
} from "./B06_Section_UI_Cross_Culvert_Wire";
import { computeFordLayout, DEFAULT_FORD_WALL_ADJUST } from "./B06_Section_UI_Cross_Ford_Geom";
import type { FordLayout } from "./B06_Section_UI_Cross_Ford_Geom";
import { computeRevetmentLayout } from "./B06_Section_UI_Cross_Revetment";
/** 같은 측점으로 볼 누가거리 오차(m) — 정본 반올림 자릿수보다 크게 잡는다. */
@@ -161,6 +162,8 @@ export const STRUCTURE_ROW_KEYS = [
"box_ground_slope_pct",
// B08 구조물 집계표 BOX 본체 길이(120) — 박스 두 끝(구체 끝) offset 차 · 같은 길로 실음.
"box_length_m",
// B08 자재 집계표 세월교 관 한 련 길이(131) — 그린 관 길이 1 m 올림 · 같은 길로 실음.
"ford_pipe_length_m",
] as const;
/** 박스 두 끝(구체 끝) 원지반 경사(%) — 유입 − 유출 ÷ 수평거리. 본체는 늘 수평이라 원지반으로(110). */
@@ -181,6 +184,14 @@ function boxLengthM(box: BoxLayout): number | null {
return run > 0 ? run : null;
}
/** 세월교 관 한 련 길이(m) — 그린 관 아래 두 꼭짓점 사이 1 m 올림(카드 라벨 `L…m` 과 같은 값 · 131). */
function fordPipeLengthM(ford: FordLayout): number | null {
const [right, left] = ford.pipe.outer.slice(2);
if (!right || !left) return null;
const length = Math.hypot(left.offset - right.offset, left.elevation - right.elevation);
return length > 0 ? Math.ceil(length - 1e-6) : null;
}
/** 폐회로 면적 입력(설계선·지반선·트림) — 트림이나 설계선이 모자라면 null. */
function areaInputOf(section: CrossSection, layouts: StoredLayouts) {
const trim = trimOfLayouts(layouts);
@@ -241,6 +252,9 @@ function areaRowOf(
const boxLength =
layouts.box && isOwner(section.box?.chainage_m) ? boxLengthM(layouts.box) : null;
if (boxLength !== null) ownRow.box_length_m = Number(boxLength.toFixed(4));
const fordLength =
layouts.ford && isOwner(section.ford?.chainage_m) ? fordPipeLengthM(layouts.ford) : null;
if (fordLength !== null) ownRow.ford_pipe_length_m = fordLength;
const pipeRow = Object.keys(ownRow).length > 1 ? ownRow : null;
const areaInput = areaInputOf(section, layouts);
if (!areaInput) return pipeRow;
+51 -31
View File
@@ -7,7 +7,9 @@
구조물 집계표 계(98 `_summary` total) = 구조물 수량 C
→ 자재수량집계표: 줄마다 단위수량(사용자 입력 · 기본 빈칸) · 수량 = C × 단위수량 · 계
→ 콘크리트소요자재집계표: 시멘트 · 모래 · 자갈 = ㎥당 배합 × 콘크리트 · 모르터 ㎥
→ 총괄자재수량집계표: 합계 = 올림(수량 × (1 + 할증)) · 커플링은 구조물 집계표 계 그대로
→ 총괄자재수량집계표: 합계 = 끝수(수량 × (1 + 할증)) · 커플링은 구조물 집계표 계 그대로
끝수 = 실무 엑셀 꼴 줄마다(레미콘 ROUND 2 · 철근 · 와이어메쉬 ROUND 3 · 시멘트 ROUND 0 ·
그 밖 ROUNDUP 0 · 131)
구조물 수량 기준 — 일위대가(B09) 재료와 별개 · 두 번 세지 않게 화면 머리에 표시.
사용자 값(`overrides`) = 프로젝트 설정 quantity › material_sheets — 고친 칸만:
@@ -73,9 +75,10 @@ CONCRETE_ROWS: list[tuple[str, str, str, str | None, dict[str, float | None]]] =
]
MIX_FIELDS = ("cement", "sand", "gravel")
#: 총괄자재수량집계표 줄 — [키, 구분, 규격, 단위, 끌어올 곳, 할증 % 기본, 올림 소수 자리]
#: 총괄자재수량집계표 줄 — [키, 구분, 규격, 단위, 끌어올 곳, 할증 % 기본, 끝수]
# 끌어올 곳 = "m:{자재}"(자재수량집계표 계) · "c:{cement|sand|gravel}"(콘크리트소요 계)
# · "cp:{관경}"(커플링)
# · "cp:{관경}"(커플링) · ""(늘 빈칸 — 막자갈은 채집이라 엑셀도 빈칸)
# 끝수 = "r{n}"(ROUND n 자리) · "u{n}"(ROUNDUP n 자리) — 실무 엑셀 G열 꼴(131)
# ÷ 는 「/1000」(kg → ton) · 「/40」(kg → 40 kg 대)
# 할증 = 건설품셈 공통 1-3-1(지식DB 01_임도/04_수량분석정보/수량산출_일반.md 3절)
# · 관급 파형강관 0
@@ -83,27 +86,28 @@ MIX_FIELDS = ("cement", "sand", "gravel")
# 기본 = 1-3-1 현장비빔(기타) 잔골재 12 · 굵은골재 5 · 시멘트 3 · 파일에 안 쓰고 읽을 때 채움
PANEL_KEY = "material_surcharge_pct" # 프로젝트 설정 quantity 칸
PANEL_SURCHARGE: dict[str, float] = {"sand": 12, "gravel": 5, "cement": 3}
TOTAL_ROWS: list[tuple[str, str, str, str, str, float | None, int]] = [
("sand", "골재", "모래", "㎥", "c:sand", PANEL_SURCHARGE["sand"], 0),
("gravel", "골재", "자갈", "㎥", "c:gravel", PANEL_SURCHARGE["gravel"], 0),
("mixed_aggregate", "골재", "혼합석", "㎥", "m:mixed_aggregate", 4, 0),
("remicon_25_24_15", "레미콘", "25-24-15", "㎥", "m:remicon_25_24_15", 1, 0), # 철근
("remicon_25_21_80", "레미콘", "25-21-80", "㎥", "m:remicon_25_21_80", 2, 0), # 무근
("remicon_25_18_80", "레미콘", "25-18-80", "㎥", "m:remicon_25_18_80", 2, 0), # 무근
("rebar_d13", "이형철근", "D13mm", "ton", "m:rebar_d13/1000", 3, 3),
("rebar_d16", "이형철근", "D16mm", "ton", "m:rebar_d16/1000", 3, 3),
("rebar_d19", "이형철근", "D19mm", "ton", "m:rebar_d19/1000", 3, 3),
("rebar_d22", "이형철근", "D22mm", "ton", "m:rebar_d22/1000", 3, 3),
("rebar_d25", "이형철근", "D25mm", "ton", "m:rebar_d25/1000", 3, 3),
("wire_mesh", "와이어메쉬", "#6, 100×100", "㎡", "m:wire_mesh", None, 0),
("cement", "시멘트", "40kg/대", "대", "c:cement/40", PANEL_SURCHARGE["cement"], 0),
("PIPES", "", "", "", "", 0, 0),
("sod", "떼", "", "㎡", "m:sod", 10, 0),
("stone_buy_35", "깬돌", "L3=35cm", "ton", "m:stone_buy_35", None, 0),
("stone_buy_45", "깬돌", "L3=45cm", "ton", "m:stone_buy_45", None, 0),
("revet_block", "호안블럭", "", "㎡", "m:revet_block", None, 0),
("flume_400c", "수로관", "400C", "본", "m:flume_400c", None, 0),
("steel_grating", "스틸그레이팅", "", "EA", "m:steel_grating", None, 0),
TOTAL_ROWS: list[tuple[str, str, str, str, str, float | None, str]] = [
("sand", "골재", "모래", "㎥", "c:sand", PANEL_SURCHARGE["sand"], "u0"),
("gravel", "골재", "자갈", "㎥", "c:gravel", PANEL_SURCHARGE["gravel"], "u0"),
("rubble", "골재", "막자갈", "㎥", "", 4, "u0"),
("mixed_aggregate", "골재", "혼합석", "㎥", "m:mixed_aggregate", 4, "u0"),
("remicon_25_24_15", "레미콘", "25-24-15", "㎥", "m:remicon_25_24_15", 1, "r2"), # 철근
("remicon_25_21_80", "레미콘", "25-21-80", "㎥", "m:remicon_25_21_80", 2, "r2"), # 무근
("remicon_25_18_80", "레미콘", "25-18-80", "㎥", "m:remicon_25_18_80", 2, "r2"), # 무근
("rebar_d13", "이형철근", "D13mm", "ton", "m:rebar_d13/1000", 3, "r3"),
("rebar_d16", "이형철근", "D16mm", "ton", "m:rebar_d16/1000", 3, "r3"),
("rebar_d19", "이형철근", "D19mm", "ton", "m:rebar_d19/1000", 3, "r3"),
("rebar_d22", "이형철근", "D22mm", "ton", "m:rebar_d22/1000", 3, "r3"),
("rebar_d25", "이형철근", "D25mm", "ton", "m:rebar_d25/1000", 3, "r3"),
("wire_mesh", "와이어메쉬", "#6, 100×100", "㎡", "m:wire_mesh", None, "r3"),
("cement", "시멘트", "40kg/대", "대", "c:cement/40", PANEL_SURCHARGE["cement"], "r0"),
("PIPES", "", "", "", "", 0, "u0"),
("sod", "떼", "", "㎡", "m:sod", 10, "u0"),
("stone_buy_35", "깬돌", "L3=35cm", "ton", "m:stone_buy_35", None, "u0"),
("stone_buy_45", "깬돌", "L3=45cm", "ton", "m:stone_buy_45", None, "u0"),
("revet_block", "호안블럭", "", "㎡", "m:revet_block", None, "u0"),
("flume_400c", "수로관", "400C", "본", "m:flume_400c", None, "u0"),
("steel_grating", "스틸그레이팅", "", "EA", "m:steel_grating", None, "u0"),
]
LENGTH_ID = re.compile(r"^pipe:len\|(\d+)$")
@@ -122,6 +126,18 @@ def roundup(value: float, places: int) -> float:
return math.ceil(round6(value) * scale - 1e-9) / scale
def round_half(value: float, places: int) -> float:
"""실무 ROUND — 반 올림(파이썬 round 의 짝수 맞춤 아님) · 떠도는 끝수는 지움."""
scale = 10**places
return math.floor(round6(value) * scale + 0.5 + 1e-9) / scale
def finish(value: float, rule: str) -> float:
"""끝수 처리 — "r{n}" = ROUND · "u{n}" = ROUNDUP(n 자리)."""
places = int(rule[1:])
return round_half(value, places) if rule[0] == "r" else roundup(value, places)
def _num(value: Any) -> float | None:
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
@@ -263,29 +279,32 @@ def compute(
for c in columns
if (m := COUPLING_ID.match(c["id"]))
}
plan: list[tuple[str, str, str, str, str, float | None, int]] = []
plan: list[tuple[str, str, str, str, str, float | None, str]] = []
for entry in TOTAL_ROWS:
if entry[0] == "PIPES":
plan += [(f"pipe_{d}", "파형강관", f"Φ{d}mm", "m", f"m:pipe_{d}", 0, 0) for d in sizes]
plan += [
(f"coupling_{d}", "커플링밴드", f"Φ{d}mm", "개", f"cp:{d}", 0, 0) for d in sizes
(f"pipe_{d}", "파형강관", f"Φ{d}mm", "m", f"m:pipe_{d}", 0, "u0") for d in sizes
]
plan += [
(f"coupling_{d}", "커플링밴드", f"Φ{d}mm", "개", f"cp:{d}", 0, "u0") for d in sizes
]
else:
plan.append(entry)
total_rows = []
for key, group, spec, unit, source, pct_default, places in plan:
for key, group, spec, unit, source, pct_default, rule in plan:
ref, _, divisor = source.partition("/")
kind, _, name = ref.partition(":")
qty: float | None = None
if kind == "m":
qty = sums.get(name)
elif kind == "c":
qty = concrete_total.get(name)
else:
elif kind == "cp":
qty = couplings.get(int(name))
if qty is not None and divisor:
qty = round6(qty / float(divisor))
pct = _num(_pick(surcharge_in, key, pct_default))
amount = roundup(qty * (1 + (pct or 0) / 100), places) if qty is not None else None
amount = finish(qty * (1 + (pct or 0) / 100), rule) if qty is not None else None
total_rows.append(
{
"key": key,
@@ -295,7 +314,8 @@ def compute(
"qty": qty,
"surcharge": pct,
"amount": amount,
"places": places,
"places": int(rule[1:]),
"rounding": "round" if rule[0] == "r" else "up",
"panel": key in PANEL_SURCHARGE,
}
)
+53 -29
View File
@@ -7,7 +7,8 @@
* 조작 중은 이 파일이 바로 셈 · [저장] 때 서버가 파이썬 짝으로 정본을 다시 셈.
*
* 구조물 집계표 계(98) = 구조물 수량 C → 자재수량집계표(C × 단위수량) → 콘크리트소요자재집계표
* (㎥당 배합 × 콘크리트 · 모르터 ㎥) → 총괄자재수량집계표(올림(수량 × (1 + 할증)) · 커플링 = 집계표 계)
* (㎥당 배합 × 콘크리트 · 모르터 ㎥) → 총괄자재수량집계표(끝수(수량 × (1 + 할증)) · 커플링 = 집계표 계)
* 끝수 = 실무 엑셀 꼴 줄마다(레미콘 ROUND 2 · 철근 · 와이어메쉬 ROUND 3 · 시멘트 ROUND 0 · 그 밖 ROUNDUP 0)
* 사용자 값 = 고친 칸만(키가 있으면 그 값 · null = 빈칸 · 없으면 기본값) — 상수 출처는 파이썬 짝 머리
* 좌측 패널 할증(`panel`) = 총괄 모래 · 자갈 · 시멘트 줄(표 사용자 값보다 앞섬)
* ========================================================================== */
@@ -61,6 +62,8 @@ export interface TotalRow {
surcharge: number | null;
amount: number | null;
places: number;
/** 끝수 = ROUND(반 올림) · ROUNDUP(올림) — 131 */
rounding: "round" | "up";
/** 할증 = 좌측 패널(표에서 안 고침) */
panel: boolean;
}
@@ -113,29 +116,31 @@ export const MIX_FIELDS = ["cement", "sand", "gravel"] as const;
/** 좌측 패널 할증(PLAN 124) — 총괄 모래 · 자갈 · 시멘트 줄 · 기본 = 1-3-1 현장비빔(기타) 12 · 5 · 3 */
export const PANEL_SURCHARGE: Record<string, number> = { sand: 12, gravel: 5, cement: 3 };
/** 총괄 줄 — 파이썬 짝 TOTAL_ROWS(할증 = 건설품셈 공통 1-3-1 · 모래 · 자갈 · 시멘트는 좌측 패널) */
type TotalEntry = [string, string, string, string, string, number | null, number];
/** 총괄 줄 — 파이썬 짝 TOTAL_ROWS(할증 = 건설품셈 공통 1-3-1 · 모래 · 자갈 · 시멘트는 좌측 패널)
* 끝수 = "r{n}" ROUND · "u{n}" ROUNDUP · 끌어올 곳 "" = 늘 빈칸(막자갈 = 채집) */
type TotalEntry = [string, string, string, string, string, number | null, string];
const TOTAL_ROWS: TotalEntry[] = [
["sand", "골재", "모래", "㎥", "c:sand", PANEL_SURCHARGE.sand, 0],
["gravel", "골재", "자갈", "㎥", "c:gravel", PANEL_SURCHARGE.gravel, 0],
["mixed_aggregate", "골재", "혼합석", "㎥", "m:mixed_aggregate", 4, 0],
["remicon_25_24_15", "레미콘", "25-24-15", "㎥", "m:remicon_25_24_15", 1, 0],
["remicon_25_21_80", "레미콘", "25-21-80", "㎥", "m:remicon_25_21_80", 2, 0],
["remicon_25_18_80", "레미콘", "25-18-80", "㎥", "m:remicon_25_18_80", 2, 0],
["rebar_d13", "이형철근", "D13mm", "ton", "m:rebar_d13/1000", 3, 3],
["rebar_d16", "이형철근", "D16mm", "ton", "m:rebar_d16/1000", 3, 3],
["rebar_d19", "이형철근", "D19mm", "ton", "m:rebar_d19/1000", 3, 3],
["rebar_d22", "이형철근", "D22mm", "ton", "m:rebar_d22/1000", 3, 3],
["rebar_d25", "이형철근", "D25mm", "ton", "m:rebar_d25/1000", 3, 3],
["wire_mesh", "와이어메쉬", "#6, 100×100", "㎡", "m:wire_mesh", null, 0],
["cement", "시멘트", "40kg/대", "대", "c:cement/40", PANEL_SURCHARGE.cement, 0],
["PIPES", "", "", "", "", 0, 0],
["sod", "떼", "", "㎡", "m:sod", 10, 0],
["stone_buy_35", "깬돌", "L3=35cm", "ton", "m:stone_buy_35", null, 0],
["stone_buy_45", "깬돌", "L3=45cm", "ton", "m:stone_buy_45", null, 0],
["revet_block", "호안블럭", "", "㎡", "m:revet_block", null, 0],
["flume_400c", "수로관", "400C", "본", "m:flume_400c", null, 0],
["steel_grating", "스틸그레이팅", "", "EA", "m:steel_grating", null, 0],
["sand", "골재", "모래", "㎥", "c:sand", PANEL_SURCHARGE.sand, "u0"],
["gravel", "골재", "자갈", "㎥", "c:gravel", PANEL_SURCHARGE.gravel, "u0"],
["rubble", "골재", "막자갈", "㎥", "", 4, "u0"],
["mixed_aggregate", "골재", "혼합석", "㎥", "m:mixed_aggregate", 4, "u0"],
["remicon_25_24_15", "레미콘", "25-24-15", "㎥", "m:remicon_25_24_15", 1, "r2"],
["remicon_25_21_80", "레미콘", "25-21-80", "㎥", "m:remicon_25_21_80", 2, "r2"],
["remicon_25_18_80", "레미콘", "25-18-80", "㎥", "m:remicon_25_18_80", 2, "r2"],
["rebar_d13", "이형철근", "D13mm", "ton", "m:rebar_d13/1000", 3, "r3"],
["rebar_d16", "이형철근", "D16mm", "ton", "m:rebar_d16/1000", 3, "r3"],
["rebar_d19", "이형철근", "D19mm", "ton", "m:rebar_d19/1000", 3, "r3"],
["rebar_d22", "이형철근", "D22mm", "ton", "m:rebar_d22/1000", 3, "r3"],
["rebar_d25", "이형철근", "D25mm", "ton", "m:rebar_d25/1000", 3, "r3"],
["wire_mesh", "와이어메쉬", "#6, 100×100", "㎡", "m:wire_mesh", null, "r3"],
["cement", "시멘트", "40kg/대", "대", "c:cement/40", PANEL_SURCHARGE.cement, "r0"],
["PIPES", "", "", "", "", 0, "u0"],
["sod", "떼", "", "㎡", "m:sod", 10, "u0"],
["stone_buy_35", "깬돌", "L3=35cm", "ton", "m:stone_buy_35", null, "u0"],
["stone_buy_45", "깬돌", "L3=45cm", "ton", "m:stone_buy_45", null, "u0"],
["revet_block", "호안블럭", "", "㎡", "m:revet_block", null, "u0"],
["flume_400c", "수로관", "400C", "본", "m:flume_400c", null, "u0"],
["steel_grating", "스틸그레이팅", "", "EA", "m:steel_grating", null, "u0"],
];
const LENGTH_ID = /^pipe:len\|(\d+)$/;
@@ -151,6 +156,22 @@ export function roundup(value: number, places: number): number {
return Math.ceil(round6(value) * scale - 1e-9) / scale;
}
/** 실무 ROUND — 반 올림 · 떠도는 끝수는 지움(파이썬 짝 round_half) */
export function roundHalf(value: number, places: number): number {
const scale = 10 ** places;
return Math.floor(round6(value) * scale + 0.5 + 1e-9) / scale;
}
/** 끝수 처리 — "r{n}" = ROUND · "u{n}" = ROUNDUP(n 자리) · 파이썬 짝 finish */
function finish(value: number, rule: string): number {
const places = Number(rule.slice(1));
return rule[0] === "r" ? roundHalf(value, places) : roundup(value, places);
}
/** 자재수량집계표에서 단위수량이 하나도 없는 줄 수 — 총괄 · 콘크리트 위 안내(131) */
export const blankUnitRows = (sheets: MaterialSheets): number =>
sheets.quantity.rows.filter((row) => Object.values(row.units).every((v) => v === null)).length;
const num = (value: unknown): number | null =>
typeof value === "number" && Number.isFinite(value) ? value : null;
@@ -266,7 +287,7 @@ export function computeMaterialSheets(
"m",
`m:pipe_${d}`,
0,
0,
"u0",
]),
...sizes.map((d): TotalEntry => [
`coupling_${d}`,
@@ -275,12 +296,12 @@ export function computeMaterialSheets(
"개",
`cp:${d}`,
0,
0,
"u0",
]),
]
: [entry],
);
const totalRows: TotalRow[] = plan.map(([key, group, spec, unit, source, pctDefault, places]) => {
const totalRows: TotalRow[] = plan.map(([key, group, spec, unit, source, pctDefault, rule]) => {
const [ref, divisor = ""] = source.split("/");
const [kind, name = ""] = ref.split(":");
let qty =
@@ -288,10 +309,12 @@ export function computeMaterialSheets(
? (sums[name] ?? null)
: kind === "c"
? (concreteTotal[name] ?? null)
: (couplings.get(Number(name)) ?? null);
: kind === "cp"
? (couplings.get(Number(name)) ?? null)
: null;
if (qty !== null && divisor) qty = round6(qty / Number(divisor));
const pct = num(pick(surchargeIn, key, pctDefault));
const amount = qty !== null ? roundup(qty * (1 + (pct ?? 0) / 100), places) : null;
const amount = qty !== null ? finish(qty * (1 + (pct ?? 0) / 100), rule) : null;
return {
key,
group,
@@ -300,7 +323,8 @@ export function computeMaterialSheets(
qty,
surcharge: pct,
amount,
places,
places: Number(rule.slice(1)),
rounding: rule[0] === "r" ? "round" : "up",
panel: key in PANEL_SURCHARGE,
};
});
@@ -41,6 +41,8 @@ async def _sheets(project_id: UUID, root: str | Path, overrides: dict[str, Any])
"source": source,
"overrides": overrides,
"surcharge_pct": panel,
# 구조물 집계표 알림(「집수정 모양 기록 없음 n곳」 등) — 총괄 · 콘크리트 위 빈 까닭(131)
"notes": list(summary.get("notes") or []),
"sheets": compute(source, overrides, panel),
}
+48 -1
View File
@@ -13,6 +13,7 @@
BOX암거 › {가로×세로} › 본체 m B06 새 키 `box_length_m`(두 끝 offset 차 · 120)
› 날개벽 › 유입 · 유출 개소
세월교 › Φ{관경}×{n}련 › 월류 폭 m · 본체 개소 · 날개벽 › 유입 · 유출 개소
관 몫 = 관 공 › 관매설 · 커플링에 더함(B06 새 키 `ford_pipe_length_m` × 련 · 131)
물넘이포장 › 월류 폭 › H={월류 높이} m
노출형 횡단수로 · 개거 · 맹암거(B05 구조물) 개소
흙막이 · 돌붙임 열은 관 · 박스 · 세월교 공용(실무 꼴) — 빈 높이: 관 = 관경 최소 ·
@@ -358,6 +359,45 @@ def _ford(
)
def _ford_pipes(
sheet: _Sheet,
at: float,
base: dict[str, Any],
length: float | None,
one_m: float | None,
names: dict[str, str],
pipes: list[dict[str, Any]],
) -> None:
"""세월교 관 몫 — 한 련 길이 × 련 수를 관매설 · 커플링 열에 더함(관 공과 같은 열 · 131).
`pipes` 에는 련마다 한 줄(좌측 패널 1본 길이로 커플링을 다시 셀 때 련 수만큼 셈).
"""
diameter = int(_num(base["options"].get("pipe_diameter_mm")) or 1000)
count = int(_num(base["options"].get("pipe_count")) or 1)
head = ["관 공", f"Φ{names.get(f'pipe.diameter={diameter}', diameter)}m/m"]
combos = [_combo(base.get("summary_item"))]
each = couplings(length, one_m)
sheet.add(
at,
f"len|{diameter}",
(2, diameter, 0),
[*head, "관매설"],
"m",
length * count if length else None,
combos,
)
sheet.add(
at,
f"cp|{diameter}",
(2, diameter, 1),
[*head, "커플링"],
"ea",
None if each is None else each * count,
combos,
)
pipes += [{"chainage_m": at, "column": f"pipe:cp|{diameter}", "length_m": length}] * count
def _pavement(
sheet: _Sheet,
at: float,
@@ -446,6 +486,7 @@ def pipe_summary(
}
basins = _basins(designs)
box_lengths = _station_values(designs, "box_length_m")
ford_lengths = _station_values(designs, "ford_pipe_length_m")
by_owner: dict[tuple[str, str], dict[str, Any]] = {}
for item in items:
if item["type_id"] not in DRAIN_TYPES:
@@ -459,7 +500,7 @@ def pipe_summary(
sheet = _Sheet()
pipes: list[dict[str, Any]] = []
no_length = no_basin = no_box = 0
no_length = no_basin = no_box = no_ford = 0
for (type_id, owner), entry in by_owner.items():
base = entry.get("base")
if base is None:
@@ -473,6 +514,10 @@ def pipe_summary(
no_box += type_id == "box_culvert" and not box_length
group = (point.group_items if point else None) or {}
_drain(sheet, at, type_id, base, options, links, group, kinds, names, box_length)
if type_id == "ford_bridge":
ford_length = fill._nearest(ford_lengths, at)
no_ford += not ford_length
_ford_pipes(sheet, at, base, ford_length, one_m, names, pipes)
continue
diameter = int(_num(base["options"].get("pipe_diameter_mm")) or 1000)
pipe_head = ["관 공", f"Φ{names.get(f'pipe.diameter={diameter}', diameter)}m/m"]
@@ -524,6 +569,8 @@ def pipe_summary(
notes.append(f"집수정 모양 기록 없음 {no_basin}곳 — B06 횡단 [저장] 뒤 채워짐")
if no_box:
notes.append(f"BOX 본체 길이 없음 {no_box}곳 — B06 횡단 [저장] 뒤 채워짐")
if no_ford:
notes.append(f"세월교 관 길이 없음 {no_ford}곳 — B06 횡단 [저장] 뒤 채워짐")
if one_m is None:
notes.append(f"1본 길이 변수({ONE_LENGTH_VAR}) 빈칸 — 커플링 빈칸")
return {
+17 -1
View File
@@ -19,6 +19,7 @@ import {
import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table";
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
import {
blankUnitRows,
computeMaterialSheets,
MIX_FIELDS,
type Cells,
@@ -35,6 +36,8 @@ interface Payload {
overrides: MaterialOverrides;
/** 좌측 패널 할증 저장본(기본 채움) */
surcharge_pct: Cells;
/** 구조물 집계표 알림(「집수정 모양 기록 없음 n곳」 등) — 총괄 · 콘크리트 위 빈 까닭(131) */
notes?: string[];
sheets: MaterialSheets;
}
@@ -42,7 +45,7 @@ const BASIS =
"구조물 수량 기준 — 일위대가 재료와 별개(두 번 세지 않게 일위대가 재료배합과 겹치지 않는지 확인)";
const HINTS: Record<MaterialSheetId, string> = {
material:
"합계 = 올림(수량 × (1 + 할증)) · 할증 % 기본 = 건설품셈 1-3-1 · 관급 파형강관 0 — 고칠 수 있음 · 모래 · 자갈 · 시멘트는 좌측 패널",
"합계 = 끝수(수량 × (1 + 할증)) — 레미콘 ROUND 2 · 철근 · 와이어메쉬 ROUND 3 · 시멘트 ROUND 0 · 그 밖 올림 · 할증 % 기본 = 건설품셈 1-3-1 · 관급 파형강관 0 — 고칠 수 있음 · 모래 · 자갈 · 시멘트는 좌측 패널",
concrete: "㎥당 = 산림품셈 12-1-3 (B) 배합 · 건축 9-1-1 모르터 기본(무근은 빈칸) — 고칠 수 있음",
quantity: "수량 = 구조물 수량 × 단위수량 · 단위수량은 빈칸(사용자 입력) · 관매설은 1",
};
@@ -252,6 +255,19 @@ export async function renderMaterialSheet(
toolbar.className = "b08-material__toolbar";
const notes = document.createElement("div");
notes.append(note(BASIS), note(HINTS[sheet]));
if (sheet !== "quantity") {
// 빈 까닭 한 줄(131) — 단위수량 빈 줄 · 서버 알림 · 빈 칸은 빈칸 그대로
const blank = blankUnitRows(sheets);
const why = [
...(blank ? [`단위수량 빈 줄 ${blank}개 — 자재수량 탭에서 넣음`] : []),
...(payload.notes ?? []),
];
if (why.length) {
const reason = note(`빈 칸 까닭 — ${why.join(" · ")}`);
reason.dataset.b08MaterialWhy = "";
notes.append(reason);
}
}
toolbar.append(notes, save);
let built = BUILD[sheet](sheets, overrides);
+3 -2
View File
@@ -32,8 +32,9 @@
flex: 0 0 auto;
}
/* 구조물 집계표 머리 — 좁은 열(44~100)에서 말줄임 대신 줄바꿈(120-2 · 공용 표 CSS 는 그대로) */
[data-b08-summary="table"] thead th {
/* 구조물 · 자재 집계표 머리 — 좁은 열(44~100)에서 말줄임 대신 줄바꿈(120-2 · 131 · 공용 표 CSS 는 그대로) */
[data-b08-summary="table"] thead th,
[data-b08-material] thead th {
white-space: normal;
overflow-wrap: anywhere;
}
@@ -150,8 +150,9 @@ def test_총괄_할증_올림_관_커플링():
assert [rows[f"pipe_{d}"]["amount"] for d in (800, 1000, 1200)] == [84, 16, 22]
assert [rows[f"coupling_{d}"]["amount"] for d in (800, 1000, 1200)] == [9, 2, 3]
assert rows["pipe_800"]["surcharge"] == 0
# 레미콘 25-18 = 올림(31 × 1.02) — 엑셀은 ROUND 2 (31.62) · 지시 = 올림
assert rows["remicon_25_18_80"]["surcharge"] == 2 and rows["remicon_25_18_80"]["amount"] == 32
# 레미콘 25-18 = ROUND(31 × 1.02, 2) = 31.62 — 엑셀 G11 꼴(131)
assert rows["remicon_25_18_80"]["surcharge"] == 2
assert rows["remicon_25_18_80"]["amount"] == 31.62
# 깬돌 = 할증 빈칸 · 올림(14.2 → 15 · 38.4 → 39) = 엑셀 G26 · G27
assert rows["stone_buy_35"]["amount"] == 15 and rows["stone_buy_45"]["amount"] == 39
assert rows["stone_buy_35"]["surcharge"] is None
@@ -0,0 +1,215 @@
"""131-1 B08 자재 집계표 고침 — 끝수 줄마다 · 막자갈 줄 · 세월교 관 길이 → 파형강관 · 커플링 ·
안내 글.
견본 = test_121 의 2공구 꼴 · test_120 의 세월교(400 · Φ1000×2련) · B06 견본 횡단(173.087 세월교).
"""
from __future__ import annotations
import asyncio
import copy
import json
import re
import subprocess
from pathlib import Path
import pytest
from test_120_b08_drain_summary import POINTS, STRUCTURES
from test_121_b08_material_sheets import SUMMARY, UNITS, _run_ts
from B08_Quantity import B08_Quantity_Router_Material as router_mod
from B08_Quantity import B08_Quantity_Router_Summary as summary_mod
from B08_Quantity.B08_Quantity_Material_Calc import clean_overrides, compute, finish
from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition
ROOT = Path(__file__).resolve().parents[2]
FIXTURES = Path(__file__).parent / "fixtures"
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
needs_node = pytest.mark.skipif(
not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치"
)
def _rows(sheets):
return {row["key"]: row for row in sheets["material"]["rows"]}
# ── 끝수 처리 줄마다(엑셀 G열 꼴) ──────────────────────────────────────────
def test_끝수_규칙():
assert finish(31.62, "r2") == 31.62 and finish(31.625, "r2") == 31.63 # 반 올림
assert finish(1.271123, "r3") == 1.271 and finish(1.271123, "u0") == 2
assert finish(243.4, "r0") == 243 and finish(243.5, "r0") == 244
assert finish(15.0000000001, "u0") == 15 # 떠도는 끝수는 안 올림
#: 철근 D13 1234.1 kg · 와이어메쉬 10.0004 ㎡ · 레미콘 25-24 10.004 ㎥ — 규칙마다 갈리는 값
EXTRA = {"pipe:chute|메붙임": {"rebar_d13": 1234.1 / 90, "wire_mesh": 10.0004 / 90}}
def _extra_over():
units = {
**UNITS,
"pipe:chute|메붙임": {**UNITS["pipe:chute|메붙임"], **EXTRA["pipe:chute|메붙임"]},
}
units["pipe:revet|돌쌓기(찰)|2.0"] = {
**UNITS["pipe:revet|돌쌓기(찰)|2.0"],
"remicon_25_24_15": 10.004 / 20,
}
return clean_overrides({"units": units})
def test_총괄_끝수_줄마다():
rows = _rows(compute(SUMMARY, _extra_over()))
assert rows["remicon_25_18_80"]["amount"] == 31.62 # ROUND(31 × 1.02, 2)
assert rows["remicon_25_24_15"]["amount"] == 10.1 # ROUND(10.004 × 1.01, 2) = 10.10
assert rows["rebar_d13"]["amount"] == 1.271 # ROUND(1.2341 × 1.03, 3)
assert rows["wire_mesh"]["amount"] == 10.0 # ROUND(10.0004, 3) — 올림 0 이면 11
cement = rows["cement"]
assert cement["amount"] == round(cement["qty"] * 1.03) # ROUND 0
assert rows["stone_buy_45"]["amount"] == 39 # 그 밖 = 올림(38.4)
assert (cement["rounding"], rows["sod"]["rounding"]) == ("round", "up")
assert (rows["rebar_d13"]["places"], rows["remicon_25_18_80"]["places"]) == (3, 2)
def test_막자갈_줄_할증_4_수량_빈칸():
sheets = compute(SUMMARY, clean_overrides({"units": UNITS}))
keys = [row["key"] for row in sheets["material"]["rows"]]
assert keys[:4] == ["sand", "gravel", "rubble", "mixed_aggregate"]
rubble = _rows(sheets)["rubble"]
assert (rubble["group"], rubble["spec"], rubble["unit"]) == ("골재", "막자갈", "㎥")
assert rubble["surcharge"] == 4
assert rubble["qty"] is None and rubble["amount"] is None # 채집 — 자재수량 계가 있어도
assert sheets["quantity"]["total"]["rubble"] == 84.94
def test_거울_끝수_막자갈():
for over in (_extra_over(), clean_overrides({"units": UNITS})):
assert _run_ts(SUMMARY, over) == compute(SUMMARY, over)
# ── 세월교 관 길이 → 구조물 집계표 관매설 · 커플링 → 총괄 파형강관 · 커플링 ────────
def _ford_summary(tmp_path: Path, designs: list[dict]) -> dict:
root = tmp_path / "proj"
edits = root / "B04_PreProcess/drainage/edits"
edits.mkdir(parents=True)
points = [{**p, "source": "user"} for p in POINTS]
(edits / "pipe_points.json").write_text(
json.dumps({"points": points}, ensure_ascii=False), encoding="utf-8"
)
route = root / "B05_Profile/route"
route.mkdir(parents=True)
(route / "structures.json").write_text(
json.dumps({"revision": 1, "structures": STRUCTURES}, ensure_ascii=False),
encoding="utf-8",
)
names = definition.read()["종류"]["pipe"]["이름"]
return summary_mod.pipe_summary(root, designs, names, 6.0)
def _cell(out: dict, at: float, cid: str):
row = next(r for r in out["rows"] if abs(r["chainage_m"] - at) < 0.01)
return row["values"].get(cid)
def test_세월교_관_길이_련수만큼_관매설_커플링(tmp_path):
designs = [
{"chainage_m": 300.0, "design": {"pipe_length_m": 18, "box_length_m": 9.6}},
{"chainage_m": 400.0, "design": {"ford_pipe_length_m": 9}},
]
out = _ford_summary(tmp_path, designs)
assert _cell(out, 400, "pipe:len|1000") == 18 # 9 m × 2련
assert _cell(out, 400, "pipe:cp|1000") == 2 # (올림(9 ÷ 6) − 1) × 2련
column = next(c for c in out["columns"] if c["id"] == "pipe:len|1000")
assert column["group"] == ["관 공", "Φ1000m/m", "관매설"]
fords = [p for p in out["pipes"] if p["column"] == "pipe:cp|1000"]
assert len(fords) == 2 and all(p["length_m"] == 9 for p in fords) # 1본 다시 셈 = 련마다
assert not any("세월교 관 길이" in n for n in out["notes"])
sheets = compute({"columns": out["columns"], "total": out["total"]}, clean_overrides({}))
rows = _rows(sheets)
assert rows["pipe_1000"]["amount"] == 18 and rows["coupling_1000"]["amount"] == 2
assert rows["pipe_800"]["amount"] == 18 # 관 몫은 그대로
def test_세월교_관_길이_없으면_빈칸_알림(tmp_path):
out = _ford_summary(tmp_path, [{"chainage_m": 300.0, "design": {"pipe_length_m": 18}}])
assert _cell(out, 400, "pipe:len|1000") is None
assert any("세월교 관 길이 없음 1곳" in n for n in out["notes"])
@needs_node
def test_서버가_세월교_관_길이를_주인_측점에만():
from B06_Section.B06_Section_Server_Calc_Prebuild import _NPM_SCRIPT, BUNDLE
from common_util.common_util_node_bundle import run_bundle_json
data = json.loads((FIXTURES / "b06_73_4_body_guard_sections.json").read_text("utf-8"))
sections = data["cross_sections"]
ford_at = next(s for s in sections if s.get("ford"))
neighbour = next(s for s in sections if s["chainage_m"] == 180)
neighbour["ford"] = copy.deepcopy(ford_at["ford"])
output = run_bundle_json(BUNDLE, _NPM_SCRIPT, {"detail": {"cross_sections": sections}})
rows = {round(r["chainage_m"], 3): r for r in output["areas"]}
length = rows[173.087]["ford_pipe_length_m"]
assert length > 0 and length == int(length) # 1 m 올림
assert "ford_pipe_length_m" not in rows.get(180, {})
def test_B06_새_키_한_벌():
from B06_Section.B06_Section_Schema import CrossSectionPatch
from B06_Section.B06_Section_Server_Calc_Prebuild import _AREA_KEYS
assert "ford_pipe_length_m" in _AREA_KEYS
assert CrossSectionPatch(chainage_m=1, ford_pipe_length_m=9).ford_pipe_length_m == 9
with pytest.raises(ValueError):
CrossSectionPatch(chainage_m=1, ford_pipe_length_m=-1)
layouts = (ROOT / "B06_Section" / "B06_Section_Structure_Layouts.ts").read_text("utf-8")
block = layouts[layouts.index("STRUCTURE_ROW_KEYS = [") :][:900]
assert '"ford_pipe_length_m"' in block
# ── 안내 글 · 열 머리 줄바꿈 ─────────────────────────────────────────────
def test_단위수량_빈_줄_수_TS():
calc = ROOT / "B08_Quantity" / "B08_Quantity_Material_Calc.ts"
full = json.dumps({"units": UNITS, "mix": {}, "surcharge": {}}, ensure_ascii=False)
body = (
f"\nconst s = {json.dumps(SUMMARY, ensure_ascii=False)};"
f"\nconst full = computeMaterialSheets(s, {full});"
"\nconst none = computeMaterialSheets(s, {units: {}, mix: {}, surcharge: {}});"
"\nconsole.log(JSON.stringify([blankUnitRows(full), blankUnitRows(none)]));"
)
script = IMPORTS.sub("", calc.read_text(encoding="utf-8")) + body
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", script],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
# 자재 줄 10(관매설 3 은 기본 단위 1) — 다 넣으면 0 · 안 넣으면 관매설 뺀 7
assert json.loads(result.stdout) == [0, 7]
def test_서버_알림이_자재_응답에(tmp_path, monkeypatch):
async def summary(_project_id):
return {**SUMMARY, "rows": [], "notes": ["집수정 모양 기록 없음 9곳 — B06 횡단 [저장] 뒤"]}
async def project_root(_project_id):
return tmp_path
monkeypatch.setattr(router_mod, "structure_summary", summary)
monkeypatch.setattr(router_mod, "_project_root", project_root)
payload = asyncio.run(router_mod.material_sheets("p"))
assert payload["notes"] == ["집수정 모양 기록 없음 9곳 — B06 횡단 [저장] 뒤"]
def test_화면_안내_글_열_머리():
ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Material.ts").read_text(encoding="utf-8")
assert "단위수량 빈 줄 ${blank}개 — 자재수량 탭에서 넣음" in ui
assert "payload.notes" in ui and 'sheet !== "quantity"' in ui
css = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Page.css").read_text(encoding="utf-8")
assert "[data-b08-material] thead th" in css
@@ -45,6 +45,8 @@ _COMPUTED_FIELDS = {
"box_ground_slope_pct",
# BOX 본체 길이(120) — 같은 다리.
"box_length_m",
# 세월교 관 한 련 길이(131) — 같은 다리.
"ford_pipe_length_m",
}