- 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
414 lines
17 KiB
TypeScript
414 lines
17 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 + 할증)) · 커플링 = 집계표 계)
|
||
* 끝수 = 실무 엑셀 꼴 줄마다(레미콘 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 },
|
||
};
|
||
}
|