Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
314 lines
12 KiB
TypeScript
314 lines
12 KiB
TypeScript
/* =============================================================================
|
||
* 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 },
|
||
};
|
||
}
|