/* ============================================================================= * B08_Quantity_Material_Calc.ts * B08 자재 집계표 셋 계산(PLAN 121-1) — 화면 없음 · 저장 안 함 · import 없음(시험이 node 로 돌림). * * ⚠ 짝 = `B08_Quantity_Material_Calc.py`(같은 식 · 같은 상수) — 한쪽을 고치면 다른 쪽도 · * 거울 시험 `resources/tester/test_121_b08_material_sheets.py`. * 조작 중은 이 파일이 바로 셈 · [저장] 때 서버가 파이썬 짝으로 정본을 다시 셈. * * 구조물 집계표 계(98) = 구조물 수량 C → 자재수량집계표(C × 단위수량) → 콘크리트소요자재집계표 * (㎥당 배합 × 콘크리트 · 모르터 ㎥) → 총괄자재수량집계표(끝수(수량 × (1 + 할증)) · 커플링 = 집계표 계) * 끝수 = 실무 엑셀 꼴 줄마다(레미콘 ROUND 2 · 철근 · 와이어메쉬 ROUND 3 · 시멘트 ROUND 0 · 그 밖 ROUNDUP 0) * 사용자 값 = 고친 칸만(키가 있으면 그 값 · null = 빈칸 · 없으면 기본값) — 상수 출처는 파이썬 짝 머리 * 좌측 패널 할증(`panel`) = 총괄 모래 · 자갈 · 시멘트 줄(표 사용자 값보다 앞섬) * ========================================================================== */ export type Cells = Record; export interface MaterialOverrides { units: Record; mix: Record; surcharge: Cells; } /** 구조물 집계표 응답(98)에서 쓰는 칸만 */ export interface SummarySource { columns: Array<{ id: string; group: string[]; label: string }>; total: Record; } export interface MaterialColumn { key: string; name: string; spec: string; unit: string; } export interface QuantityRow { id: string; name: string; spec: string; unit: string; qty: number | null; units: Cells; amounts: Cells; } export interface ConcreteRow { key: string; name: string; spec: string; qty: number | null; per: Cells; amounts: Cells; } export interface TotalRow { key: string; group: string; spec: string; unit: string; qty: number | null; surcharge: number | null; amount: number | null; places: number; /** 끝수 = ROUND(반 올림) · ROUNDUP(올림) — 131 */ rounding: "round" | "up"; /** 할증 = 좌측 패널(표에서 안 고침) */ panel: boolean; } export interface MaterialSheets { quantity: { materials: MaterialColumn[]; rows: QuantityRow[]; total: Record }; concrete: { rows: ConcreteRow[]; total: Record }; material: { rows: TotalRow[] }; } type Entry = [string, string, string, string]; /** 자재수량집계표 자재 열 — 실무 엑셀 F~AD(파형강관은 관경마다) · 파이썬 짝 MATERIALS */ const MATERIALS: Entry[] = [ ["remicon_25_24_15", "레미콘", "25-24-15", "㎥"], ["remicon_25_21_80", "레미콘", "25-21-80", "㎥"], ["remicon_25_18_80", "레미콘", "25-18-80", "㎥"], ["concrete_machine", "콘크리트", "기계", "㎥"], ["rebar_d13", "이형철근", "D13", "kg"], ["rebar_d16", "이형철근", "D16", "kg"], ["rebar_d19", "이형철근", "D19", "kg"], ["rebar_d22", "이형철근", "D22", "kg"], ["rebar_d25", "이형철근", "D25", "kg"], ["PIPES", "", "", ""], ["mortar_1_3", "모르터", "1:3", "㎥"], ["revet_block", "호안블럭", "", "㎡"], ["wire_mesh", "와이어메쉬", "", "㎡"], ["flume_400c", "수로관", "400C", "본"], ["steel_grating", "스틸그레이팅", "", "EA"], ["stone_buy_35", "돌(구입)", "L3:35", "ton"], ["stone_buy_45", "돌(구입)", "L3:45", "ton"], ["stone_pick_35", "돌(채집)", "L3:35", "㎥"], ["stone_pick_45", "돌(채집)", "L3:45", "㎥"], ["bed_stone", "고임돌", "채집", "㎥"], ["rubble", "막자갈", "채집", "㎥"], ["mixed_aggregate", "혼합골재", "", "㎥"], ["sod", "떼", "", "㎡"], ]; /** 콘크리트소요 줄 — 파이썬 짝 CONCRETE_ROWS(12-1-3 QF000353 (B) · CC000337 · 건축 9-1-1 QC001347) */ const CONCRETE_ROWS: Array<[string, string, string, string | null, Cells]> = [ ["c25", "콘크리트", "Φ25mm", null, { cement: 346, sand: 0.487, gravel: 0.562 }], ["c40", "콘크리트", "Φ40mm", "concrete_machine", { cement: 323, sand: 0.456, gravel: 0.612 }], ["c40_plain", "콘크리트", "Φ40mm(무근)", null, { cement: null, sand: null, gravel: null }], ["m12", "모르터", "1:2", null, { cement: 680, sand: 0.98, gravel: null }], ["m13", "모르터", "1:3", "mortar_1_3", { cement: 510, sand: 1.1, gravel: null }], ]; 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 · 모래 · 자갈 · 시멘트는 좌측 패널) * 끝수 = "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, "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+)$/; const COUPLING_ID = /^pipe:cp\|(\d+)$/; const UNITS = ["m", "㎡", "㎥", "ea", "개", "개소", "EA"]; /** 소수 6 자리 반올림(떠도는 끝수만 지움 · 엑셀처럼 줄마다 자르지 않음)(파이썬 짝 round6 과 같은 반 올림) */ export const round6 = (value: number): number => Math.floor(value * 1e6 + 0.5) / 1e6; /** 실무 ROUNDUP — 떠도는 끝수(1e-9)는 올리지 않음 */ export function roundup(value: number, places: number): number { const scale = 10 ** places; 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; const pick = (table: Cells, key: string, fallback: number | null | undefined): unknown => key in table ? table[key] : fallback; /** 좌측 패널 할증 % — 받은 칸 · 없거나 수가 아니면 기본 */ export function panelSurcharge(raw: Cells | null | undefined): Record { const out: Record = {}; for (const [key, fallback] of Object.entries(PANEL_SURCHARGE)) out[key] = num(raw?.[key]) ?? fallback; return out; } export const emptyOverrides = (): MaterialOverrides => ({ units: {}, mix: {}, surcharge: {} }); export function diameters(columns: SummarySource["columns"]): number[] { const found = new Set(); for (const column of columns) { const match = LENGTH_ID.exec(column.id) ?? COUPLING_ID.exec(column.id); if (match) found.add(Number(match[1])); } return [...found].sort((a, b) => a - b); } function materialColumns(sizes: number[]): Entry[] { return MATERIALS.flatMap((entry): Entry[] => entry[0] === "PIPES" ? sizes.map((d): Entry => [`pipe_${d}`, "파형강관", `Φ${d}`, "m"]) : [entry], ); } /** 집계표 열 머리 → [구분, 규격, 단위] — 맨 아래 머리가 단위가 아니면(관보호공 모양) 규격에 붙임 */ function structureRow(column: SummarySource["columns"][number]): [string, string, string] { const [name = "", ...spec] = (column.group ?? []).map(String); const label = String(column.label ?? ""); if (UNITS.includes(label)) return [name, spec.join(" "), label]; return [name, [...spec, label].join(" "), "개소"]; } const add = (sums: Record, key: string, value: number | null): void => { if (value !== null) sums[key] = round6((sums[key] ?? 0) + value); }; export function computeMaterialSheets( summary: SummarySource, overrides: MaterialOverrides, panel?: Cells | null, ): MaterialSheets { const columns = summary.columns ?? []; const total = summary.total ?? {}; const unitsIn = overrides.units ?? {}; const sizes = diameters(columns); const materials = materialColumns(sizes); const keys = materials.map((m) => m[0]); const rows: QuantityRow[] = []; const sums: Record = {}; for (const column of columns) { const cid = column.id; if (COUPLING_ID.test(cid)) continue; const [name, spec, unit] = structureRow(column); const qty = num(total[cid]); const length = LENGTH_ID.exec(cid); const defaults: Cells = length ? { [`pipe_${length[1]}`]: 1 } : {}; const given = unitsIn[cid] ?? {}; const units: Cells = {}; const amounts: Cells = {}; for (const k of keys) { const u = num(pick(given, k, defaults[k])); units[k] = u; amounts[k] = qty !== null && u !== null ? round6(qty * u) : null; add(sums, k, amounts[k]); } rows.push({ id: cid, name, spec, unit, qty, units, amounts }); } const quantity = { materials: materials.map(([key, name, spec, unit]) => ({ key, name, spec, unit })), rows, total: sums, }; const mixIn = overrides.mix ?? {}; const concreteRows: ConcreteRow[] = []; const concreteTotal: Record = {}; for (const [key, name, spec, source, mix] of CONCRETE_ROWS) { const qty = source ? (sums[source] ?? null) : null; const given = mixIn[key] ?? {}; const per: Cells = {}; const amounts: Cells = {}; for (const f of MIX_FIELDS) { per[f] = num(pick(given, f, mix[f])); amounts[f] = qty !== null && per[f] !== null ? round6(qty * per[f]!) : null; add(concreteTotal, f, amounts[f]); } concreteRows.push({ key, name, spec, qty, per, amounts }); } const surchargeIn: Cells = { ...(overrides.surcharge ?? {}), ...panelSurcharge(panel) }; const couplings = new Map(); for (const column of columns) { const match = COUPLING_ID.exec(column.id); if (match) couplings.set(Number(match[1]), num(total[column.id])); } const plan = TOTAL_ROWS.flatMap((entry): TotalEntry[] => entry[0] === "PIPES" ? [ ...sizes.map((d): TotalEntry => [ `pipe_${d}`, "파형강관", `Φ${d}mm`, "m", `m:pipe_${d}`, 0, "u0", ]), ...sizes.map((d): TotalEntry => [ `coupling_${d}`, "커플링밴드", `Φ${d}mm`, "개", `cp:${d}`, 0, "u0", ]), ] : [entry], ); const totalRows: TotalRow[] = plan.map(([key, group, spec, unit, source, pctDefault, rule]) => { const [ref, divisor = ""] = source.split("/"); const [kind, name = ""] = ref.split(":"); let qty = kind === "m" ? (sums[name] ?? null) : kind === "c" ? (concreteTotal[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 ? finish(qty * (1 + (pct ?? 0) / 100), rule) : null; return { key, group, spec, unit, qty, surcharge: pct, amount, places: Number(rule.slice(1)), rounding: rule[0] === "r" ? "round" : "up", panel: key in PANEL_SURCHARGE, }; }); return { quantity, concrete: { rows: concreteRows, total: concreteTotal }, material: { rows: totalRows }, }; }