Files
Aislo/B08_Quantity/B08_Quantity_Material_Calc.ts
T

314 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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 + 할증)) · 커플링 = 집계표 계)
* 사용자 값 = 고친 칸만(키가 있으면 그 값 · null = 빈칸 · 없으면 기본값) — 상수 출처는 파이썬 짝 머리
* 좌측 패널 할증(`panel`) = 총괄 모래 · 자갈 · 시멘트 줄(표 사용자 값보다 앞섬)
* ========================================================================== */
export type Cells = Record<string, number | null>;
export interface MaterialOverrides {
units: Record<string, Cells>;
mix: Record<string, Cells>;
surcharge: Cells;
}
/** 구조물 집계표 응답(98)에서 쓰는 칸만 */
export interface SummarySource {
columns: Array<{ id: string; group: string[]; label: string }>;
total: Record<string, number>;
}
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;
/** 할증 = 좌측 패널(표에서 안 고침) */
panel: boolean;
}
export interface MaterialSheets {
quantity: { materials: MaterialColumn[]; rows: QuantityRow[]; total: Record<string, number> };
concrete: { rows: ConcreteRow[]; total: Record<string, number> };
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<string, number> = { sand: 12, gravel: 5, cement: 3 };
/** 총괄 줄 — 파이썬 짝 TOTAL_ROWS(할증 = 건설품셈 공통 1-3-1 · 모래 · 자갈 · 시멘트는 좌측 패널) */
type TotalEntry = [string, string, string, string, string, number | null, number];
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],
];
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;
}
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<string, number> {
const out: Record<string, number> = {};
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<number>();
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<string, number>, 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<string, number> = {};
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<string, number> = {};
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<number, number | null>();
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,
0,
]),
...sizes.map((d): TotalEntry => [
`coupling_${d}`,
"커플링밴드",
`Φ${d}mm`,
"개",
`cp:${d}`,
0,
0,
]),
]
: [entry],
);
const totalRows: TotalRow[] = plan.map(([key, group, spec, unit, source, pctDefault, places]) => {
const [ref, divisor = ""] = source.split("/");
const [kind, name = ""] = ref.split(":");
let qty =
kind === "m"
? (sums[name] ?? null)
: kind === "c"
? (concreteTotal[name] ?? null)
: (couplings.get(Number(name)) ?? 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;
return {
key,
group,
spec,
unit,
qty,
surcharge: pct,
amount,
places,
panel: key in PANEL_SURCHARGE,
};
});
return {
quantity,
concrete: { rows: concreteRows, total: concreteTotal },
material: { rows: totalRows },
};
}