diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index a9fcba04a..64b1be3f5 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -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; diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index c70f5dff8..dc8d2bf2d 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -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): diff --git a/B06_Section/B06_Section_Server_Calc_Prebuild.py b/B06_Section/B06_Section_Server_Calc_Prebuild.py index 248cc7b9c..875f81b34 100644 --- a/B06_Section/B06_Section_Server_Calc_Prebuild.py +++ b/B06_Section/B06_Section_Server_Calc_Prebuild.py @@ -69,6 +69,7 @@ _AREA_KEYS = ( "pipe_slope_pct", "box_ground_slope_pct", "box_length_m", + "ford_pipe_length_m", ) diff --git a/B06_Section/B06_Section_Structure_Layouts.ts b/B06_Section/B06_Section_Structure_Layouts.ts index fe697f0bd..ab49b6b31 100644 --- a/B06_Section/B06_Section_Structure_Layouts.ts +++ b/B06_Section/B06_Section_Structure_Layouts.ts @@ -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; diff --git a/B08_Quantity/B08_Quantity_Material_Calc.py b/B08_Quantity/B08_Quantity_Material_Calc.py index ab9ee186d..0cfa8dc40 100644 --- a/B08_Quantity/B08_Quantity_Material_Calc.py +++ b/B08_Quantity/B08_Quantity_Material_Calc.py @@ -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, } ) diff --git a/B08_Quantity/B08_Quantity_Material_Calc.ts b/B08_Quantity/B08_Quantity_Material_Calc.ts index ba8cd9382..d5c595f5a 100644 --- a/B08_Quantity/B08_Quantity_Material_Calc.ts +++ b/B08_Quantity/B08_Quantity_Material_Calc.ts @@ -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 = { 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, }; }); diff --git a/B08_Quantity/B08_Quantity_Router_Material.py b/B08_Quantity/B08_Quantity_Router_Material.py index bd19e0d57..7df1ca4ce 100644 --- a/B08_Quantity/B08_Quantity_Router_Material.py +++ b/B08_Quantity/B08_Quantity_Router_Material.py @@ -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), } diff --git a/B08_Quantity/B08_Quantity_Router_Summary.py b/B08_Quantity/B08_Quantity_Router_Summary.py index 5acc9a281..95e78bd6a 100644 --- a/B08_Quantity/B08_Quantity_Router_Summary.py +++ b/B08_Quantity/B08_Quantity_Router_Summary.py @@ -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 { diff --git a/B08_Quantity/B08_Quantity_UI_Material.ts b/B08_Quantity/B08_Quantity_UI_Material.ts index a484165b1..7d032c8a9 100644 --- a/B08_Quantity/B08_Quantity_UI_Material.ts +++ b/B08_Quantity/B08_Quantity_UI_Material.ts @@ -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 = { 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); diff --git a/B08_Quantity/B08_Quantity_UI_Page.css b/B08_Quantity/B08_Quantity_UI_Page.css index 9848cf30a..46eb16e07 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.css +++ b/B08_Quantity/B08_Quantity_UI_Page.css @@ -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; } diff --git a/resources/tester/test_121_b08_material_sheets.py b/resources/tester/test_121_b08_material_sheets.py index 2b6f8f182..adadb712e 100644 --- a/resources/tester/test_121_b08_material_sheets.py +++ b/resources/tester/test_121_b08_material_sheets.py @@ -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 diff --git a/resources/tester/test_131_b08_material_fix.py b/resources/tester/test_131_b08_material_fix.py new file mode 100644 index 000000000..fa1fb00d1 --- /dev/null +++ b/resources/tester/test_131_b08_material_fix.py @@ -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 diff --git a/resources/tester/test_b06_design_key_split.py b/resources/tester/test_b06_design_key_split.py index cb0bfee71..3dc1d0089 100644 --- a/resources/tester/test_b06_design_key_split.py +++ b/resources/tester/test_b06_design_key_split.py @@ -45,6 +45,8 @@ _COMPUTED_FIELDS = { "box_ground_slope_pct", # BOX 본체 길이(120) — 같은 다리. "box_length_m", + # 세월교 관 한 련 길이(131) — 같은 다리. + "ford_pipe_length_m", }