Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user