feat(quantity): ④ 전체 합계에 교량 전체 집계 표를 추가
- 교각 목록의 "전체"를 "전체 합계 (N기)"로 표시 - 전체를 고르고 2기 이상이면 강종·직경별 교량 전체 집계를 맨 위에 표시 (소계, 합계, 할증 포함, 커플러) - 집계 규칙을 core/quantity-total.ts로 분리하고 테스트 3건 추가. 교각별 표는 그대로 Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7861bc3fdf
commit
1ecd7542ee
4 files changed
+133
-10
No files matched your search
@@ -0,0 +1,59 @@
|
||||
import type { Quantity } from './rebar/quantity';
|
||||
|
||||
/** One line of the bridge total: a 강종 and 직경 summed over the piers. */
|
||||
export interface TotalRow {
|
||||
grade: number;
|
||||
size: string;
|
||||
count: number;
|
||||
totalM: number;
|
||||
kg: number;
|
||||
}
|
||||
|
||||
export interface QuantityTotal {
|
||||
rows: TotalRow[];
|
||||
/** Totals by 강종, in the same order as the rows. */
|
||||
gradeKg: { grade: number; kg: number }[];
|
||||
kg: number;
|
||||
kgWithWaste: number;
|
||||
ton: number;
|
||||
couplers: { name: string; count: number }[];
|
||||
/** Bar names with no length, left out of the totals (summed over the piers). */
|
||||
missingCount: number;
|
||||
}
|
||||
|
||||
/** 교량 전체 집계: sums the per-pier 재료표 by 강종 and 직경 (the per-pier values are not changed). */
|
||||
export function aggregateQuantities(items: Quantity[]): QuantityTotal {
|
||||
const byKey = new Map<string, TotalRow>();
|
||||
const couplers = new Map<string, number>();
|
||||
let kg = 0;
|
||||
let kgWithWaste = 0;
|
||||
let missingCount = 0;
|
||||
for (const q of items) {
|
||||
for (const g of q.groups) {
|
||||
for (const d of g.diameters) {
|
||||
const key = `${g.grade}|${d.size}`;
|
||||
const row = byKey.get(key) ?? { grade: g.grade, size: d.size, count: 0, totalM: 0, kg: 0 };
|
||||
row.count += d.lines.reduce((n, l) => n + l.count, 0);
|
||||
row.totalM += d.totalM;
|
||||
row.kg += d.kg;
|
||||
byKey.set(key, row);
|
||||
}
|
||||
}
|
||||
for (const c of q.couplers) couplers.set(c.name, (couplers.get(c.name) ?? 0) + c.count);
|
||||
kg += q.kg;
|
||||
kgWithWaste += q.kgWithWaste;
|
||||
missingCount += q.missing.length;
|
||||
}
|
||||
const rows = [...byKey.values()].sort((a, b) => a.grade - b.grade || parseInt(a.size.slice(1), 10) - parseInt(b.size.slice(1), 10));
|
||||
const gradeKg = new Map<number, number>();
|
||||
for (const r of rows) gradeKg.set(r.grade, (gradeKg.get(r.grade) ?? 0) + r.kg);
|
||||
return {
|
||||
rows,
|
||||
gradeKg: [...gradeKg].map(([grade, value]) => ({ grade, kg: value })),
|
||||
kg,
|
||||
kgWithWaste,
|
||||
ton: kgWithWaste / 1000,
|
||||
couplers: [...couplers].map(([name, count]) => ({ name, count })),
|
||||
missingCount,
|
||||
};
|
||||
}
|
||||
+32
-10
@@ -11,6 +11,7 @@ import { adjustRow, layoutText, resolveRowCounts, rowLayout, totalPiles } from '
|
||||
import { applyCopy, COPY_ITEMS, planCopy, type CopyItem } from './core/copy';
|
||||
import { copyCandidates, copyItemsFor, copyItemsWithOverrides, hasCopyItems, selectedTargets, type CopyCandidate } from './core/copy-targets';
|
||||
import { quantityTargetNames } from './core/quantity-scope';
|
||||
import { aggregateQuantities, type QuantityTotal } from './core/quantity-total';
|
||||
import { pileGeometry } from './core/geometry';
|
||||
import { rebarSummary, type RebarRow } from './core/rebar/summary';
|
||||
import { shearSegments } from './core/rebar/coping';
|
||||
@@ -1814,28 +1815,49 @@ function renderQuantityScreen(): void {
|
||||
renderPierRows($<HTMLUListElement>('quantity-pier-items'), 'quantity');
|
||||
const allButton = $<HTMLButtonElement>('quantity-all');
|
||||
const generated = state.piers.filter((p) => p.model).length;
|
||||
allButton.textContent = `전체 (${generated}기)`;
|
||||
allButton.textContent = `전체 합계 (${generated}기)`;
|
||||
allButton.setAttribute('aria-pressed', String(state.quantityAll));
|
||||
const targets = quantityTargets();
|
||||
$('step4-status').textContent = targets.length ? (state.quantityAll ? `${targets.length}기` : targets[0].input.name) : '예정';
|
||||
$('quantity-note').textContent = targets.length
|
||||
? `${state.quantityAll ? `전체 ${targets.length}기` : targets[0].input.name} · 할증 ${state.material.waste}% (기본 0%, 가정). 삿갓 길이는 h − 100으로 가정(Q-K5). 교각 목록에서 고른 교각만 보며, 전체를 누르면 모든 교각을 보여 줍니다.`
|
||||
: '계산된 교각이 없습니다. 교각을 생성하세요.';
|
||||
let kg = 0;
|
||||
let kgWithWaste = 0;
|
||||
for (const pier of targets) {
|
||||
if (!pier.model) continue;
|
||||
const q = buildQuantity(rebarSummary(pier.model, state.material, state.fckByMember).rows, state.material);
|
||||
kg += q.kg;
|
||||
kgWithWaste += q.kgWithWaste;
|
||||
const entries = targets.flatMap((pier) =>
|
||||
pier.model ? [{ pier, q: buildQuantity(rebarSummary(pier.model, state.material, state.fckByMember).rows, state.material) }] : [],
|
||||
);
|
||||
// 전체 with more than one pier: the bridge total first (교량 전체 집계); the per-pier tables follow.
|
||||
if (state.quantityAll && entries.length > 1) body.appendChild(bridgeTotalBlock(aggregateQuantities(entries.map((e) => e.q)), entries.length));
|
||||
for (const { pier, q } of entries) {
|
||||
body.appendChild(el('h3', `${pier.input.name} · ${pier.input.type}`));
|
||||
if (q.missing.length) body.appendChild(el('p', `길이 미산정 ${q.missing.length}개는 합계에서 뺐습니다: ${q.missing.join(', ')}`, 'muted small'));
|
||||
body.appendChild(quantityTable(q));
|
||||
body.appendChild(el('p', `합계 ${q.kg.toFixed(1)} kg · 할증 포함 ${q.kgWithWaste.toFixed(1)} kg (${q.ton.toFixed(3)} ton) · 커플러 ${q.couplers.map((c) => `${c.name} ${c.count}개`).join(', ') || '없음'}`, 'pnote'));
|
||||
}
|
||||
if (targets.length > 1) {
|
||||
body.appendChild(el('p', `전체 ${targets.length}기 합계 ${kg.toFixed(1)} kg · 할증 포함 ${kgWithWaste.toFixed(1)} kg (${(kgWithWaste / 1000).toFixed(3)} ton)`, 'lbl'));
|
||||
}
|
||||
|
||||
/** 교량 전체 집계: the 강종·직경 totals over every pier on ④ (전체). */
|
||||
function bridgeTotalBlock(total: QuantityTotal, pierCount: number): HTMLElement {
|
||||
const wrap = el('section', undefined, 'quantity-total');
|
||||
wrap.appendChild(el('h3', `교량 전체 집계 · ${pierCount}기`));
|
||||
const table = el('table', undefined, 'grid-table rebar-table');
|
||||
const head = el('thead');
|
||||
const hr = el('tr');
|
||||
for (const h of ['강종', '직경', '개수', '총길이 (m)', '총중량 (kg)']) hr.appendChild(el('th', h));
|
||||
head.appendChild(hr);
|
||||
table.appendChild(head);
|
||||
const body = el('tbody');
|
||||
for (const r of total.rows) {
|
||||
body.appendChild(el('tr')).append(el('td', `SD${r.grade}`), el('td', r.size), el('td', String(r.count)), el('td', r.totalM.toFixed(1)), el('td', r.kg.toFixed(1)));
|
||||
}
|
||||
for (const g of total.gradeKg) {
|
||||
body.appendChild(el('tr', undefined, 'subtotal')).append(el('td', `SD${g.grade} 소계`), el('td', ''), el('td', ''), el('td', ''), el('td', g.kg.toFixed(1)));
|
||||
}
|
||||
table.appendChild(body);
|
||||
wrap.appendChild(table);
|
||||
const couplers = total.couplers.map((c) => `${c.name} ${c.count}개`).join(', ') || '없음';
|
||||
const missing = total.missingCount ? ` · 길이 미산정 ${total.missingCount}개 제외` : '';
|
||||
wrap.appendChild(el('p', `합계 ${total.kg.toFixed(1)} kg · 할증 포함 ${total.kgWithWaste.toFixed(1)} kg (${total.ton.toFixed(3)} ton) · 커플러 ${couplers}${missing}`, 'pnote'));
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/** Excel: one sheet per pier and a 합계 sheet (승인 2026-10-23). */
|
||||
|
||||
@@ -837,3 +837,4 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
|
||||
.copy-panel .cp-chips .chk:has(input:checked) { border-color: #1f5fa8; background: #e3edf8; }
|
||||
.copy-panel .cp-chips .chk:has(input:disabled) { color: #8a94a3; background: #f4f6f9; }
|
||||
|
||||
.quantity-total { margin-bottom: 16px; }
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { aggregateQuantities } from '../src/core/quantity-total';
|
||||
import type { Quantity } from '../src/core/rebar/quantity';
|
||||
|
||||
/** A 재료표 with one 강종·직경 line; only the fields the aggregate reads are filled. */
|
||||
function quantity(grade: number, size: string, count: number, totalM: number, kg: number, extra: Partial<Quantity> = {}): Quantity {
|
||||
return {
|
||||
groups: [{ grade, kg, diameters: [{ size, dMm: 19, lines: [{ count } as never], totalM, kg }] }],
|
||||
couplers: [],
|
||||
missing: [],
|
||||
kg,
|
||||
wastePercent: 0,
|
||||
kgWithWaste: kg,
|
||||
ton: kg / 1000,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
describe('aggregateQuantities (교량 전체 집계)', () => {
|
||||
it('sums the same 강종·직경 over the piers', () => {
|
||||
const total = aggregateQuantities([quantity(400, 'H19', 10, 100, 225), quantity(400, 'H19', 5, 50, 112.5)]);
|
||||
expect(total.rows).toEqual([{ grade: 400, size: 'H19', count: 15, totalM: 150, kg: 337.5 }]);
|
||||
expect(total.kg).toBe(337.5);
|
||||
});
|
||||
|
||||
it('keeps different 강종 and 직경 apart and orders them by 강종 then 직경', () => {
|
||||
const total = aggregateQuantities([quantity(500, 'S19', 4, 40, 90), quantity(400, 'H25', 2, 20, 50), quantity(400, 'H19', 1, 10, 22.5)]);
|
||||
expect(total.rows.map((r) => `${r.grade}|${r.size}`)).toEqual(['400|H19', '400|H25', '500|S19']);
|
||||
expect(total.gradeKg).toEqual([{ grade: 400, kg: 72.5 }, { grade: 500, kg: 90 }]);
|
||||
});
|
||||
|
||||
it('adds the 할증 total and the couplers across the piers', () => {
|
||||
const total = aggregateQuantities([
|
||||
{ ...quantity(400, 'H19', 1, 1, 100, { kgWithWaste: 110, couplers: [{ name: '커플러 H19', count: 3 }] }) },
|
||||
{ ...quantity(400, 'H19', 1, 1, 200, { kgWithWaste: 220, couplers: [{ name: '커플러 H19', count: 2 }] }) },
|
||||
]);
|
||||
expect(total.kgWithWaste).toBe(330);
|
||||
expect(total.ton).toBeCloseTo(0.33, 6);
|
||||
expect(total.couplers).toEqual([{ name: '커플러 H19', count: 5 }]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user