Files
Aislo/B08_Quantity/B08_Quantity_Material_Calc.ts
T
eomsangdonandClaude Opus 5.5 c6bb3cc5b6 feat(b08): 자재수량 → 총괄 · 콘크리트소요 · 채집석 공제 엑셀 흐름(160-3 · 4 · 5)
- 160-3 맞춤 없는 사본 자재 = 총괄 끝 줄(이름 · 규격 · 단위 그대로 · 알림에 이름 목록) · 맞춤 = 이름 · 규격 · 단위 · 기계 주연료 · 할증(ℓ) 뺌 · 참조 줄(승격 · 사전조합) 안 재료 셈 · 고른 품목 = MT 코드 · 파이썬 · TS 짝
- 160-4 콘크리트(기계) · 모르터 1:3 열 늘 보임(손 단위수량 → 콘크리트소요) · 엑셀 손값과 다른 배합 · 할증은 표 아래 알림 한 줄
- 160-5 채집석 공제(돌 채집 둘 · 고임돌 · 막자갈 합) = 총괄 줄 · 토공 잔토처리 = ROUND 0(잔토 − 공제) · 공제 줄 보임 · TS 짝
- 시험 test_160_3 · 4 · 5 새 · test_143_3 · 143_4 · 69 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sDZSjFvoiDjdGpgj3muc3
2026-10-09 22:53:18 +09:00

414 lines
17 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 + 할증)) · 커플링 = 집계표 계)
* 끝수 = 실무 엑셀 꼴 줄마다(레미콘 ROUND 2 · 철근 · 와이어메쉬 ROUND 3 · 시멘트 ROUND 0 · 그 밖 ROUNDUP 0)
* 사용자 값 = 고친 칸만(키가 있으면 그 값 · null = 빈칸 · 없으면 기본값) — 상수 출처는 파이썬 짝 머리
* 좌측 패널 할증(`panel`) = 총괄 모래 · 자갈 · 시멘트 줄(표 사용자 값보다 앞섬)
* 단위수량 기본값(143-4) = 서버가 낸 사본 자재(`summary.snapshot`) · 자재 열 = 사본 자재 + 관경 ·
* 2공구 고정 열은 사용자 값 있을 때만(콘크리트(기계) · 모르터 1:3 은 늘 · 160-4) ·
* 콘크리트소요 · 총괄 끌어옴 = 이름 · 규격 · 단위 맞춤(`matched`) · 맞춤 없음 = 총괄 끝 줄(160-3)
* 채집석 공제(160-5) = 채집재 넷 계 합 → 총괄 줄 · 토공 잔토처리에서 뺌
* ========================================================================== */
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>;
/** 연결된 사본 자재(143-4 · 서버 셈) — 열 · 집계표 열마다 단위수량 기본 */
snapshot?: { materials?: MaterialColumn[]; units?: Record<string, Cells> };
}
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<string, number>;
/** 2공구 고정 열에 안 맞은 사본 자재 키(143-4) — 총괄 끝에 그대로 줄(160-3) */
unmatched: string[];
};
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;
/** 늘 보이는 2공구 고정 열 — 콘크리트소요 원천(엑셀 I66 · R66 · 160-4) · 파이썬 짝 ALWAYS */
const ALWAYS = ["concrete_machine", "mortar_1_3"];
/** 채집석 공제(160-5) — 채집재 넷 계 합(엑셀 Y68) · 파이썬 짝 PICKED · DEDUCT_KEY */
const PICKED = ["stone_pick_35", "stone_pick_45", "bed_stone", "rubble"];
export const DEDUCT_KEY = "picked_deduct";
/** 좌측 패널 할증(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 · 모래 · 자갈 · 시멘트는 좌측 패널)
* 끝수 = "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<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);
}
/** 자재 열 — 고정 열은 `shown`(사용자 값 있음) · 관경 · 사본 자재(`extra`) 뒤 · 파이썬 짝 */
function materialColumns(
sizes: number[],
shown: Set<string>,
extra: MaterialColumn[] = [],
): Entry[] {
const out = MATERIALS.flatMap((entry): Entry[] =>
entry[0] === "PIPES"
? sizes.map((d): Entry => [`pipe_${d}`, "파형강관", `Φ${d}`, "m"])
: shown.has(entry[0]) || ALWAYS.includes(entry[0])
? [entry]
: [],
);
const have = new Set(out.map((m) => m[0]));
for (const m of extra) if (!have.has(m.key)) out.push([m.key, m.name, m.spec, m.unit]);
return out;
}
const norm = (text: string): string =>
String(text ?? "")
.replace(/\s+/g, "")
.toLowerCase();
/** 단위 맞춤 글 — 빈칸 · 대소문 무시 · m3 = ㎥ · m2 = ㎡(파이썬 짝 `_unit`) */
const unitOf = (text: string): string => norm(text).replace(/m3/g, "㎥").replace(/m2/g, "㎡");
/** 사본 자재 → 2공구 고정 열 키 — 이름에 고정 이름 · 규격이 다 듦 + 단위 같음 · 첫 맞춤(파이썬 짝 `matched`) */
export function matched(name: string, spec: string, unit: string): string | null {
const text = norm(`${name}${spec}`);
for (const [key, fixed, fixedSpec, fixedUnit] of MATERIALS)
if (
key !== "PIPES" &&
text.includes(norm(fixed)) &&
text.includes(norm(fixedSpec)) &&
unitOf(unit) === unitOf(fixedUnit)
)
return key;
return null;
}
/** 집계표 열 머리 → [구분, 규격, 단위] — 맨 아래 머리가 단위가 아니면(관보호공 모양) 규격에 붙임 */
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 linkedUnits = summary.snapshot?.units ?? {};
const sizes = diameters(columns);
const shown = new Set<string>();
for (const cells of Object.values(unitsIn))
for (const [k, v] of Object.entries(cells)) if (v !== null) shown.add(k);
const materials = materialColumns(sizes, shown, summary.snapshot?.materials);
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 = { ...(linkedUnits[cid] ?? {}) }; // 사본 자재(143-4)
if (length) defaults[`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 });
}
// 끌어옴(143-4) — 고정 · 관경 열은 제 키 · 사본 자재는 이름 · 규격 맞춤 고정 키로
const fixed = new Set([...MATERIALS.map((m) => m[0]), ...sizes.map((d) => `pipe_${d}`)]);
const pulled: Record<string, number> = {};
const unmatched: string[] = [];
for (const [key, name, spec, unit] of materials) {
if (!(key in sums)) continue;
const target = fixed.has(key) ? key : matched(name, spec, unit);
if (target === null) unmatched.push(key);
else add(pulled, target, sums[key]);
}
const quantity = {
materials: materials.map(([key, name, spec, unit]) => ({ key, name, spec, unit })),
rows,
total: sums,
unmatched,
};
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 ? (pulled[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,
"u0",
]),
...sizes.map((d): TotalEntry => [
`coupling_${d}`,
"커플링밴드",
`Φ${d}mm`,
"개",
`cp:${d}`,
0,
"u0",
]),
]
: [entry],
);
// 채집석 공제(160-5) · 맞춤 없는 사본 자재(160-3) — 끝수 없음(r6) · 할증 기본 빈칸
plan.push([DEDUCT_KEY, "채집석 공제", "돌(채집) · 고임돌 · 막자갈", "㎥", "picked", null, "r6"]);
for (const k of unmatched) {
const [, name, spec, unit] = materials.find((m) => m[0] === k)!;
plan.push([k, name, spec, unit, `m:${k}`, null, "r6"]);
}
const picked = PICKED.filter((k) => k in pulled).map((k) => pulled[k]);
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"
? (pulled[name] ?? (unmatched.includes(name) ? sums[name] : null))
: kind === "picked"
? picked.length
? round6(picked.reduce((a, b) => a + b, 0))
: 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 = key === DEDUCT_KEY ? null : 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 },
};
}