feat(rebar): 철근재료표 by 강종 → 직경 → 번호 with unit weights, 할증 and Excel export

The quantity table sits below the 철근표 for the whole pier. Lines group by grade and nominal diameter, with
subtotals, unit weight 0.00617·d² (kg/m), the total in kg and ton, and a 할증 total. 할증 is an input in 공통 설정,
default 0 as an assumption. Couplers are counted, not weighed. Bars with no length yet (C9, C10, C11, C12, P1, P2) are
listed as missing and kept out of the totals. The table exports to Excel with SheetJS.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-10-23 10:16:56 +09:00
1 parent 30e5e75c22
commit b6bf087334
7 files changed
+242 -5

No files matched your search

+6 -3
View File
@@ -49,9 +49,11 @@ export interface Material {
grade: Record<GradeKey, number>;
/** 굽힘 내면반경 배수 (입력). */
bend: BendFactors;
/** 재료표 할증 (%, 입력). Default 0 is an assumption. */
waste: number;
}
export const DEFAULT_MATERIAL: Material = { fck: 27, grade: { D: 300, H: 400, S: 500 }, bend: DEFAULT_BEND };
export const DEFAULT_MATERIAL: Material = { fck: 27, grade: { D: 300, H: 400, S: 500 }, bend: DEFAULT_BEND, waste: 0 };
/** Yield strength (MPa) of a bar from its 규격, e.g. H19 → the H grade. */
export function fyOf(m: Material, size: string): number {
@@ -63,7 +65,7 @@ export function fyOf(m: Material, size: string): number {
* A value outside GRADE_CHOICES falls back to the default for that prefix.
*/
export function materialFrom(raw: unknown): Material {
const r = (raw ?? {}) as { fck?: unknown; grade?: Partial<Record<GradeKey, unknown>>; fyH?: unknown; fyS?: unknown; bend?: Partial<Record<keyof BendFactors, unknown>> };
const r = (raw ?? {}) as { fck?: unknown; grade?: Partial<Record<GradeKey, unknown>>; fyH?: unknown; fyS?: unknown; bend?: Partial<Record<keyof BendFactors, unknown>> ; waste?: unknown };
const factor = (v: unknown, fallback: number) => (typeof v === 'number' && v > 0 ? v : fallback);
const bend: BendFactors = {
small16: factor(r.bend?.small16, DEFAULT_BEND.small16),
@@ -77,7 +79,8 @@ export function materialFrom(raw: unknown): Material {
H: pick(r.grade?.H ?? r.fyH, DEFAULT_MATERIAL.grade.H),
S: pick(r.grade?.S ?? r.fyS, DEFAULT_MATERIAL.grade.S),
};
return { fck: typeof r.fck === 'number' ? r.fck : DEFAULT_MATERIAL.fck, grade, bend };
const waste = typeof r.waste === 'number' && r.waste >= 0 ? r.waste : DEFAULT_MATERIAL.waste;
return { fck: typeof r.fck === 'number' ? r.fck : DEFAULT_MATERIAL.fck, grade, bend, waste };
}
/** Members that can take their own fck on ② (부재 카드). Other members always use the 공통 fck. */
+122
View File
@@ -0,0 +1,122 @@
/**
* 철근재료표 (quantity table): bars grouped by 강종 (grade) and nominal diameter, with unit weights and totals.
* Pure functions. Rows with no length yet are listed as missing and left out of the totals (no guessed lengths).
*/
import { fyOf, Material, NOMINAL_DIAMETER, type BarSize } from './common';
import type { RebarRow } from './summary';
/** Unit weight (kg/m) of a bar of nominal diameter d (mm): 0.00617 · d². H19 (19.1) → 2.25. */
export function unitWeight(dMm: number): number {
return 0.00617 * dMm * dMm;
}
/** True for a 규격 that is a rebar size (H19, S32, D13 …), not a coupler label. */
export function isBarSize(size: string): size is BarSize {
return size in NOMINAL_DIAMETER;
}
export interface QuantityLine {
name: string;
size: string;
member: RebarRow['member'];
count: number;
/** Length of one bar (mm). */
length: number;
/** Total length (m) = count × length. */
totalM: number;
kgPerM: number;
/** Total weight (kg) before the 할증. */
kg: number;
shape: string;
}
export interface QuantityDiameter {
size: string;
dMm: number;
lines: QuantityLine[];
totalM: number;
kg: number;
}
export interface QuantityGroup {
grade: number;
diameters: QuantityDiameter[];
kg: number;
}
export interface Quantity {
groups: QuantityGroup[];
couplers: { name: string; count: number }[];
/** Bar names with no length yet; they are not in the totals. */
missing: string[];
kg: number;
/** 할증 applied to the total, in percent (입력, 기본 0 가정). */
wastePercent: number;
kgWithWaste: number;
ton: number;
}
export function buildQuantity(rows: RebarRow[], material: Material): Quantity {
const byGrade = new Map<number, Map<string, QuantityDiameter>>();
const couplers: { name: string; count: number }[] = [];
const missing: string[] = [];
for (const r of rows) {
if (r.name.includes('커플러')) {
couplers.push({ name: r.name, count: r.count });
continue;
}
if (!isBarSize(r.size)) continue;
if (r.length === null) {
missing.push(r.name);
continue;
}
const grade = fyOf(material, r.size);
const dMm = NOMINAL_DIAMETER[r.size];
const kgPerM = unitWeight(dMm);
const totalM = (r.count * r.length) / 1000;
const line: QuantityLine = { name: r.name, size: r.size, member: r.member, count: r.count, length: r.length, totalM, kgPerM, kg: totalM * kgPerM, shape: r.shape };
const diameters = byGrade.get(grade) ?? new Map<string, QuantityDiameter>();
const d = diameters.get(r.size) ?? { size: r.size, dMm, lines: [], totalM: 0, kg: 0 };
d.lines.push(line);
d.totalM += totalM;
d.kg += line.kg;
diameters.set(r.size, d);
byGrade.set(grade, diameters);
}
const groups: QuantityGroup[] = [...byGrade.entries()]
.sort(([a], [b]) => a - b)
.map(([grade, diameters]) => {
const list = [...diameters.values()].sort((a, b) => a.dMm - b.dMm);
return { grade, diameters: list, kg: list.reduce((s, d) => s + d.kg, 0) };
});
const kg = groups.reduce((s, g) => s + g.kg, 0);
const wastePercent = material.waste;
const kgWithWaste = kg * (1 + wastePercent / 100);
return { groups, couplers, missing, kg, wastePercent, kgWithWaste, ton: kgWithWaste / 1000 };
}
/** The 철근재료표 as rows (for the Excel export): group and diameter headers, lines, subtotals, totals. */
export function quantityRows(q: Quantity): (string | number)[][] {
const out: (string | number)[][] = [['강종', '번호', '형상', '규격', '개수', '길이 (mm)', '총길이 (m)', '단위중량 (kg/m)', '총중량 (kg)']];
for (const g of q.groups) {
for (const d of g.diameters) {
for (const l of d.lines) {
out.push([`SD${g.grade}`, l.name, l.shape, l.size, l.count, l.length, round(l.totalM), round(l.kgPerM, 3), round(l.kg)]);
}
out.push(['', `${d.size} 소계`, '', d.size, '', '', round(d.totalM), '', round(d.kg)]);
}
out.push([`SD${g.grade} 소계`, '', '', '', '', '', '', '', round(g.kg)]);
}
out.push(['합계', '', '', '', '', '', '', '', round(q.kg)]);
out.push([`할증 ${q.wastePercent}% 포함`, '', '', '', '', '', '', '', round(q.kgWithWaste)]);
out.push(['총중량 (ton)', '', '', '', '', '', '', '', round(q.ton, 3)]);
for (const c of q.couplers) out.push(['커플러 (개수만)', c.name, '', '', c.count, '', '', '', '']);
return out;
}
function round(v: number, digits = 1): number {
const f = 10 ** digits;
return Math.round(v * f) / f;
}