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
This commit is contained in:
2026-10-09 22:53:18 +09:00
co-authored by Claude Opus 5.5
parent 774560d480
commit c6bb3cc5b6
13 changed files with 542 additions and 51 deletions
@@ -15,6 +15,8 @@ Q · L 출처 = 저장된 유토곡선 배분(`mass_haul.haul_plan` — B07 토
풍화암 · 보통암 · 표토제거 · 법면보호공 · 운반성토 줄은 값이 없어 비움(None) · 구조물 쪽 줄(su2 시트
구조물터파기 · 토사다짐 · 되메우기 · 잔토)은 뺌 · 거창 계수(성토면다짐 0.8 · 초류종자 성토 0.5 ·
지장목 0.8)는 근거가 없어 값 빈칸 + 「비율 미정」(PLAN 143-2).
채집석 공제(160-5) = 자재 총괄 「채집석 공제」 값(돌(채집) 둘 · 고임돌 · 막자갈 · 2공구 엑셀
토적집계표 H32 = −자재수량집계표!Y68) → 잔토처리 = ROUND(잔토 − 공제) · 공제 줄은 잔토처리 아래.
짝 — `rock_split` · `split_rock_columns` ↔ `B08_Quantity_Options_Calc.ts` `rockSplit` ·
`splitRockColumns` · `splitRockSummary`(거울 시험 `test_122_b08_options_mirror`).
@@ -93,8 +95,10 @@ def summary_rows(
totals: dict[str, float],
haul: dict[str, dict[str, Any]],
soft_pct: float = SOFT_ROCK_PCT_DEFAULT,
deduct: float | None = None,
) -> list[dict[str, Any]]:
"""토공집계표 줄 — 시트 C5:G37 차례 · 식 그대로(셀 주소는 줄 끝 주석) · 암 = 연암 % 로 나눔."""
"""토공집계표 줄 — 시트 C5:G37 차례 · 식 그대로(셀 주소는 줄 끝 주석) · 암 = 연암 % 로 나눔 ·
`deduct` = 채집석 공제 ㎥(160-5 · 없으면 빈칸)."""
t = totals
cut_soft, cut_hard = rock_split(t["cut_rock_volume_m3"], soft_pct)
ditch_soft, ditch_hard = rock_split(t["ditch_rock_volume_m3"], soft_pct)
@@ -121,7 +125,8 @@ def summary_rows(
_row("종방향유용토", "", "백호우", m3, free_q, "유용토"), # 종방향무대운반거리!B25
_row("토사운반", "", "BH+DT+BH", m3, dump_q), # 평균운반거리!B42
_row("토사운반", "", "19 TON B/D", m3, dozer_q), # 평균운반거리 불도우저 ΣQ
_row("잔토처리", "", "백호우", m3, r0(t["balance_m3"])), # 토적표!T137
_row("잔토처리", "", "백호우", m3, r0(t["balance_m3"] - (deduct or 0))), # 토적표!T137
_row("채집석 공제", "", "돌(채집) · 고임돌 · 막자갈", m3, deduct, "잔토처리에서 뺌"),
_row("층따기", "", "백호우", m3, t["benching_area_m2"]), # 토적표!W137
_row("성토면다짐", "", "", "㎡", None, unsure), # 거창 = 성토면 × 0.8
_row("표토제거", "", "", "㎡", None),
+39 -12
View File
@@ -15,9 +15,12 @@
단위수량 기본값(143-4) = 연결된 사본 일위대가 자재 · 로직 재료(`summary.snapshot` · 서버가
사본 셈으로 냄 — `B08_Quantity_Router_Material.snapshot_units`) · 자재 열 = 그 사본 자재
(키 = MT 코드 · 로직 재료 = 이름|단위) + 관경 · 2공구 고정 열(`MATERIALS`)은 사용자 값이 있을
때만 · 콘크리트소요 ·
총괄로 끌어옴 = 이름 · 규격 맞춤(`matched`) · 안 맞는 사본 자재 = `unmatched`(화면 알림) ·
때만(콘크리트(기계) · 모르터 1:3 = 콘크리트소요 원천이라 늘 · 160-4) · 콘크리트소요 ·
총괄로 끌어옴 = 이름 · 규격 · 단위 맞춤(`matched`) · 안 맞는 사본 자재 = `unmatched` → 총괄 끝에
그 이름 · 규격 · 단위 그대로 줄(할증 빈칸 · 끝수 없음 · 160-3) ·
사본 없거나 그 자재가 없으면 빈칸 · 관매설 → 파형강관 1 은 그대로 · 손 값이 이김.
채집석 공제(160-5) = 돌(채집) 둘 · 고임돌 · 막자갈 계 합(엑셀 Y68 · 끝수 없음) → 총괄 줄 · 토공
잔토처리에서 뺌(`B08_Quantity_Engine_Earthwork_Summary.summary_rows`).
사용자 값(`overrides`) = 프로젝트 설정 quantity › material_sheets — 고친 칸만:
{"units": {줄 id: {자재: 값}}, "mix": {배합 줄: {cement|sand|gravel: 값}},
@@ -81,6 +84,11 @@ CONCRETE_ROWS: list[tuple[str, str, str, str | None, dict[str, float | None]]] =
("m13", "모르터", "1:3", "mortar_1_3", {"cement": 510, "sand": 1.10, "gravel": None}),
]
MIX_FIELDS = ("cement", "sand", "gravel")
#: 늘 보이는 2공구 고정 열 — 콘크리트소요가 끌어오는 두 열(엑셀 I66 · R66 · 160-4)
ALWAYS = ("concrete_machine", "mortar_1_3")
#: 채집석 공제(160-5) — 현장 채집재 넷 → 총괄로 안 보내고 합을 토공 잔토처리에서 뺌(엑셀 Y68)
PICKED = ("stone_pick_35", "stone_pick_45", "bed_stone", "rubble")
DEDUCT_KEY = "picked_deduct"
#: 총괄자재수량집계표 줄 — [키, 구분, 규격, 단위, 끌어올 곳, 할증 % 기본, 끝수]
# 끌어올 곳 = "m:{자재}"(자재수량집계표 계) · "c:{cement|sand|gravel}"(콘크리트소요 계)
@@ -201,7 +209,7 @@ def material_columns(
for entry in MATERIALS:
if entry[0] == "PIPES":
out += [(f"pipe_{d}", "파형강관", f"Φ{d}", "m") for d in sizes]
elif shown is None or entry[0] in shown:
elif shown is None or entry[0] in shown or entry[0] in ALWAYS:
out.append(entry)
have = {m[0] for m in out}
for m in extra or []:
@@ -214,13 +222,23 @@ def _norm(text: str) -> str:
return re.sub(r"\s+", "", str(text or "")).lower()
def matched(name: str, spec: str) -> str | None:
def _unit(text: str) -> str:
"""단위 맞춤 글 — 빈칸 · 대소문 무시 · m3 = ㎥ · m2 = ㎡."""
return _norm(text).replace("m3", "㎥").replace("m2", "㎡")
def matched(name: str, spec: str, unit: str) -> str | None:
"""사본 자재 → 2공구 고정 열 키(콘크리트소요 · 총괄 끌어옴) — 이름에 고정 이름 · 규격이 다
듦 · 첫 맞춤. ponytail: 글 맞춤 — 품목 이름이 실무 이름과 다르면 「맞춤 없음」(맞춤 표는
필요할 때)."""
듦 + 단위 같음(kg ↔ ton 은 맞춤 아님 · 160-3) · 첫 맞춤. ponytail: 글 맞춤 — 품목 이름이
실무 이름과 다르면 「맞춤 없음」(총괄 끝 줄 · 맞춤 표는 146-3)."""
text = _norm(f"{name}{spec}")
for key, fixed, fixed_spec, _ in MATERIALS:
if key != "PIPES" and _norm(fixed) in text and _norm(fixed_spec) in text:
for key, fixed, fixed_spec, fixed_unit in MATERIALS:
if (
key != "PIPES"
and _norm(fixed) in text
and _norm(fixed_spec) in text
and _unit(unit) == _unit(fixed_unit)
):
return key
return None
@@ -286,10 +304,10 @@ def compute(
fixed = {m[0] for m in MATERIALS} | {f"pipe_{d}" for d in sizes}
pulled: dict[str, float] = {}
unmatched: list[str] = []
for key, name, spec, _ in materials:
for key, name, spec, unit in materials:
if key not in sums:
continue
target = key if key in fixed else matched(name, spec)
target = key if key in fixed else matched(name, spec, unit)
if target is None:
unmatched.append(key)
else:
@@ -337,20 +355,29 @@ def compute(
]
else:
plan.append(entry)
# 채집석 공제(160-5) · 맞춤 없는 사본 자재(160-3) — 끝수 없음(r6) · 할증 기본 빈칸
plan.append(
(DEDUCT_KEY, "채집석 공제", "돌(채집) · 고임돌 · 막자갈", "㎥", "picked", None, "r6")
)
by_key = {m[0]: m for m in materials}
plan += [(k, by_key[k][1], by_key[k][2], by_key[k][3], f"m:{k}", None, "r6") for k in unmatched]
picked = [pulled[k] for k in PICKED if k in pulled]
total_rows = []
for key, group, spec, unit, source, pct_default, rule in plan:
ref, _, divisor = source.partition("/")
kind, _, name = ref.partition(":")
qty: float | None = None
if kind == "m":
qty = pulled.get(name)
qty = pulled.get(name, sums.get(name) if name in unmatched else None)
elif kind == "picked":
qty = round6(sum(picked)) if picked else None
elif kind == "c":
qty = concrete_total.get(name)
elif kind == "cp":
qty = couplings.get(int(name))
if qty is not None and divisor:
qty = round6(qty / float(divisor))
pct = _num(_pick(surcharge_in, key, pct_default))
pct = None if key == DEDUCT_KEY else _num(_pick(surcharge_in, key, pct_default))
amount = finish(qty * (1 + (pct or 0) / 100), rule) if qty is not None else None
total_rows.append(
{
+43 -16
View File
@@ -12,7 +12,9 @@
* 사용자 값 = 고친 칸만(키가 있으면 그 값 · null = 빈칸 · 없으면 기본값) — 상수 출처는 파이썬 짝 머리
* 좌측 패널 할증(`panel`) = 총괄 모래 · 자갈 · 시멘트 줄(표 사용자 값보다 앞섬)
* 단위수량 기본값(143-4) = 서버가 낸 사본 자재(`summary.snapshot`) · 자재 열 = 사본 자재 + 관경 ·
* 2공구 고정 열은 사용자 값 있을 때만 · 콘크리트소요 · 총괄 끌어옴 = 이름 · 규격 맞춤(`matched`)
* 2공구 고정 열은 사용자 값 있을 때만(콘크리트(기계) · 모르터 1:3 은 늘 · 160-4) ·
* 콘크리트소요 · 총괄 끌어옴 = 이름 · 규격 · 단위 맞춤(`matched`) · 맞춤 없음 = 총괄 끝 줄(160-3)
* 채집석 공제(160-5) = 채집재 넷 계 합 → 총괄 줄 · 토공 잔토처리에서 뺌
* ========================================================================== */
export type Cells = Record<string, number | null>;
@@ -77,7 +79,7 @@ export interface MaterialSheets {
materials: MaterialColumn[];
rows: QuantityRow[];
total: Record<string, number>;
/** 2공구 고정 열에 안 맞아 콘크리트소요 · 총괄로 못 끌어온 사본 자재 키(143-4) */
/** 2공구 고정 열에 안 맞은 사본 자재 키(143-4) — 총괄 끝에 그대로 줄(160-3) */
unmatched: string[];
};
concrete: { rows: ConcreteRow[]; total: Record<string, number> };
@@ -122,6 +124,11 @@ const CONCRETE_ROWS: Array<[string, string, string, string | null, Cells]> = [
["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 };
@@ -216,7 +223,7 @@ function materialColumns(
const out = MATERIALS.flatMap((entry): Entry[] =>
entry[0] === "PIPES"
? sizes.map((d): Entry => [`pipe_${d}`, "파형강관", `Φ${d}`, "m"])
: shown.has(entry[0])
: shown.has(entry[0]) || ALWAYS.includes(entry[0])
? [entry]
: [],
);
@@ -230,11 +237,20 @@ const norm = (text: string): string =>
.replace(/\s+/g, "")
.toLowerCase();
/** 사본 자재 → 2공구 고정 열 키 — 이름에 고정 이름 · 규격이 다 듦 · 첫 맞춤(파이썬 짝 `matched`) */
export function matched(name: string, spec: string): string | null {
/** 단위 맞춤 글 — 빈칸 · 대소문 무시 · 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] of MATERIALS)
if (key !== "PIPES" && text.includes(norm(fixed)) && text.includes(norm(fixedSpec))) return key;
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;
}
@@ -291,9 +307,9 @@ export function computeMaterialSheets(
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] of materials) {
for (const [key, name, spec, unit] of materials) {
if (!(key in sums)) continue;
const target = fixed.has(key) ? key : matched(name, spec);
const target = fixed.has(key) ? key : matched(name, spec, unit);
if (target === null) unmatched.push(key);
else add(pulled, target, sums[key]);
}
@@ -350,19 +366,30 @@ export function computeMaterialSheets(
]
: [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] ?? null)
: kind === "c"
? (concreteTotal[name] ?? null)
: kind === "cp"
? (couplings.get(Number(name)) ?? null)
: null;
? (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 = num(pick(surchargeIn, key, pctDefault));
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,
+4 -2
View File
@@ -160,18 +160,20 @@ export function recountRockC(
/** 엑셀 ROUND(정수) — 반은 0 에서 먼 쪽. */
const excelRound = (value: number): number => Math.sign(value) * Math.round(Math.abs(value));
/** 토공집계표 보정량계 · 유용토 소계 · 횡방향유용토 · 잔토처리 줄 — 합계에서 다시(종방향 무대 Q 는 그대로). */
/** 토공집계표 보정량계 · 유용토 소계 · 횡방향유용토 · 잔토처리 줄 — 합계에서 다시(종방향 무대 Q 는 그대로) ·
* 잔토처리 = 잔토 − 「채집석 공제」 줄 값(160-5 · 서버가 낸 값 그대로). */
export function recountAdjustedSummary<R extends { group: string; value: number | null }>(
rows: readonly R[],
totals: Record<string, number>,
freeQ: number,
): R[] {
const diverted = totals.diverted_m3 ?? 0;
const deduct = rows.find((row) => row.group === "채집석 공제")?.value ?? 0;
const values: Record<string, number> = {
보정량계: excelRound(totals.adjusted_total_m3 ?? 0),
"유용토 소계": diverted + freeQ,
횡방향유용토: diverted,
잔토처리: excelRound(totals.balance_m3 ?? 0),
잔토처리: excelRound((totals.balance_m3 ?? 0) - deduct),
};
return rows.map((row) => (row.group in values ? { ...row, value: values[row.group] } : row));
}
@@ -9,6 +9,7 @@
토량환산계수 = 유토곡선과 같은 `mixed_conversion_factors(quantity_settings)` · 암 = 연암 · 경암 비율 가중 C ·
`rock_c` = 가중 전 리핑암 · 발파암 C(조작 중 브라우저가 다시 가중 · 125-1).
암 → 연암 · 경암 = 암 × 연암 %(`rock_ratios_pct` · 기본 90 · PLAN 122) — 토적표 줄 · 합계 · 토공집계표.
잔토처리 = 잔토 − 채집석 공제(자재 총괄 저장본 · `picked_deduction` · 160-5).
"""
from __future__ import annotations
@@ -32,6 +33,7 @@ from B08_Quantity.B08_Quantity_Engine_Earthwork_Summary import (
summary_rows,
)
from B08_Quantity.B08_Quantity_Router_Hydro import _project_root
from B08_Quantity.B08_Quantity_Router_Material import picked_deduction
from common_util.common_util_project_settings import (
earthwork_conversion_factors,
mixed_conversion_factors,
@@ -63,9 +65,10 @@ async def earthwork_table(project_id: UUID) -> dict[str, Any]:
split_rock_columns(record, soft)
mass_haul = ((section or {}).get("data") or {}).get("mass_haul") or {}
haul = haul_lists(mass_haul.get("haul_plan"))
deduct = await picked_deduction(project_id, root)
return {
**table,
"summary": summary_rows(table["totals"], haul, soft),
"summary": summary_rows(table["totals"], haul, soft, deduct),
"rock_ratios_pct": {"연암": soft, "경암": 100.0 - soft},
"rock_c": {
kind: entry["compacted"]
+103 -10
View File
@@ -9,12 +9,15 @@
좌측 패널 값(`surcharge_pct` · 저장은 옵션 길 PUT …/quantity/options)
기본값은 파일에 안 씀(읽을 때 채움).
단위수량 기본(143-4) = 연결된 사본 셈(`M02_Item_Copy.calc` = 층 셈 한 벌)의 일위대가 자재 ·
로직 재료(`snapshot_units` · 서버 단독 — 브라우저는 받은 값으로 짝 계산).
로직 재료(`snapshot_units` · 서버 단독 — 브라우저는 받은 값으로 짝 계산) · 참조 줄(승격 · 사전조합)
안 재료도 셈 · 기계 주연료 · 할증(ℓ)은 뺌(원가 몫 · 160-3).
"""
from __future__ import annotations
import asyncio
import re
from decimal import Decimal
from pathlib import Path
from typing import Any
from uuid import UUID
@@ -22,6 +25,7 @@ from uuid import UUID
from fastapi import APIRouter, Body
from B08_Quantity.B08_Quantity_Material_Calc import (
DEDUCT_KEY,
PANEL_KEY,
_num,
clean_overrides,
@@ -37,19 +41,75 @@ from common_util.common_util_summary_item import GROUP_JOIN
router = APIRouter(prefix="/api/projects", tags=["B08 Material"])
SETTINGS_KEY = "material_sheets"
#: 기계 주연료 · 할증 · 잡유(ℓ) — 기계경비(원가) 몫 · 자재 표에서 뺌(160-3).
# ponytail: 이름 글 맞춤 — 새 연료 이름이 나오면 여기에 더함
FUEL = re.compile(r"연료|경유|휘발유|중유|등유|잡유|gasoline", re.IGNORECASE)
def _fuel(name: str, unit: str) -> bool:
return unit in ("ℓ", "L") and FUEL.search(name) is not None
def _qty(value: Any) -> float | None:
return _num(float(value) if isinstance(value, Decimal) else value)
def _ref_mats(whole: Any, read: Any, ref: dict, trail: tuple[str, ...] = ()) -> list[tuple]:
"""참조 문서 1 단위 재료 `[(재료 줄, 수량)]` — 층 셈 펼침(`M02_Labor_Ref.Expand`)과 같은 길:
승격 = 원 로직 재료 ÷ 배 · 사전조합 = 안 줄 재료 × 손 수량(빈칸 1) · 로직 줄은 알약 배로 나눔 ·
순환 · 깊이 넘음 · 없는 문서 = 빈 것."""
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_Labor_Ref as refs
rid = refs.ref_id(ref)
doc = read(ref)
if rid in trail or len(trail) >= refs.DEPTH or not isinstance(doc, dict):
return []
def run(one: dict, times: Any) -> list[tuple]:
got = labor.run_line(
whole,
str(one.get("로직") or ""),
one.get("입력값"),
one.get("켜쌓기율"),
one.get("고름"),
)
div = _qty(times) or 1.0
return [(m, q / div) for m in got.get("재료") or [] if (q := _qty(m.get("수량")))]
lines = [one for one in doc.get("담은로직") or [] if isinstance(one, dict)]
if refs.kind_of(ref["키"]) == "promote":
return run(lines[0], doc.get("배")) if lines else []
kept = doc.get("알약")
pills = kept if isinstance(kept, list) else combo_store.combo_pills({"담은로직": lines})["물량"]
by_logic = {code: pill for pill in pills for code in pill.get("로직") or []}
out: list[tuple] = []
for one in lines:
if isinstance(one.get("참조"), dict):
sub = _ref_mats(whole, read, one["참조"], (*trail, rid))
elif one.get("로직"):
sub = run(one, (by_logic.get(one["로직"]) or {}).get("배"))
else:
continue
hand = _qty((one.get("수량") or {}).get("손"))
out += [(m, q * (1.0 if hand is None else hand)) for m, q in sub]
return out
def _folder_units(root: str | Path, folder: str, meta: dict[str, dict]) -> dict[str, float]:
"""사본 하나 = 집계 항목 1 단위의 자재 `{키: 수량}` — 일위대가 자재(키 = MT 코드) + 로직 재료
(키 = 이름|단위 · 재료 수량 × 줄 수량 ÷ 배) · 줄 수량 빈칸 · 0 · 파형강관은 안 담음 · 셈 못 하면
빈 것. ponytail: 참조 줄(승격 ·
사전조합) 안 재료는 안 셈 — 펼친 재료가 셈 답에 오면 그때."""
(키 = 고른 품목 MT 코드 · 없으면 이름|단위 · 재료 수량 × 줄 수량 ÷ 배) + 참조 줄 안 재료
(160-3) · 줄 수량 빈칸 · 0 · 파형강관 · 기계 연료는 안 담음 · 셈 못 하면 빈 것."""
from M02_MasterTemplete import M02_Item_Copy as copies
from M02_MasterTemplete.M02_Item_Source import PRICE_FILE, read_json
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete.M02_Item_Prices import load_prices
from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, PRICE_FILE, read_json
try:
got = copies.calc(root, folder)
bundle = read_json(copies.folder_of(root, folder) / PRICE_FILE) or {}
labor = read_json(copies.folder_of(root, folder) / LABOR_FILE) or {}
except (LookupError, ValueError):
return {}
rows = {
@@ -67,12 +127,35 @@ def _folder_units(root: str | Path, folder: str, meta: dict[str, dict]) -> dict[
meta.setdefault(key, {"key": key, "name": name, "spec": spec, "unit": unit})
out[key] = out.get(key, 0.0) + qty
def logic_mat(mat: dict, qty: float) -> None:
name, unit = str(mat.get("이름") or ""), str(mat.get("단위") or "")
if _fuel(name, unit):
return
goods = mat.get("품목")
code = str(goods.get("키") or "") if isinstance(goods, dict) else ""
if row := rows.get(code) if code else None: # 고른 품목 = 이름 · 규격 맞춤(160-3)
put(code, str(row.get("이름") or name), str(row.get("규격") or ""), unit, qty)
else:
put(f"{name}|{unit}", name, "", unit, qty)
frozen = (labor.get("참조사본") if isinstance(labor, dict) else None) or {}
whole = None # 사본 단가 — 참조 줄이 있을 때만 읽음
def read(ref: dict) -> Any:
return frozen.get(refs.ref_id(ref))
for line in got.get("줄") or []:
qty, times = _num(line.get("수량")), _num(line.get("배")) or 1.0
for mat in line.get("재료") or [] if qty is not None else []:
if qty is None:
continue
if isinstance(line.get("참조"), dict): # 참조 줄 = 배 1
whole = load_prices(bundle) if whole is None else whole
for mat, per in _ref_mats(whole, read, line["참조"]):
logic_mat(mat, per * qty)
continue
for mat in line.get("재료") or []:
if (per := _num(mat.get("수량"))) is not None:
name, unit = str(mat.get("이름") or ""), str(mat.get("단위") or "")
put(f"{name}|{unit}", name, "", unit, per * qty / times)
logic_mat(mat, per * qty / times)
for mat in got.get("자재") or []:
code, qty = str(mat.get("코드") or ""), _num(mat.get("수량"))
if code and qty is not None:
@@ -126,9 +209,12 @@ async def _sheets(project_id: UUID, root: str | Path, overrides: dict[str, Any])
sheets = compute(source, overrides, panel)
notes = list(summary.get("notes") or [])
if lost := sheets["quantity"]["unmatched"]:
notes.append(
f"사본 자재 {len(lost)}종 — 2공구 열 맞춤 없음(콘크리트소요 · 총괄로 안 끌어옴)"
names = {m["key"]: m for m in sheets["quantity"]["materials"]}
listed = " · ".join(
" ".join(filter(None, (names[k]["name"], names[k]["spec"], names[k]["unit"])))
for k in lost
)
notes.append(f"2공구 열 맞춤 없음 {len(lost)}종(총괄 끝 줄 그대로) — {listed}")
return {
"source": source,
"overrides": overrides,
@@ -139,6 +225,13 @@ async def _sheets(project_id: UUID, root: str | Path, overrides: dict[str, Any])
}
async def picked_deduction(project_id: UUID, root: str | Path) -> float | None:
"""채집석 공제 ㎥(160-5) — 저장본 기준 총괄 「채집석 공제」 줄 값 · 토공 잔토처리에서 뺌."""
overrides = clean_overrides(quantity_settings(root).get(SETTINGS_KEY))
rows = (await _sheets(project_id, root, overrides))["sheets"]["material"]["rows"]
return next((row["amount"] for row in rows if row["key"] == DEDUCT_KEY), None)
@router.get("/{project_id}/quantity/materials")
async def material_sheets(project_id: UUID) -> dict[str, Any]:
root = await _project_root(project_id)
+48 -4
View File
@@ -8,6 +8,8 @@
* 총괄 모래 · 자갈 · 시멘트 할증 = 좌측 패널 값(PLAN 124 · 표에서 안 고침 · 패널 조작에 바로 다시 셈)
* [저장] = PUT …/quantity/materials → 서버가 정본으로 다시 셈 · 캐시 비움 · 자동 저장 없음
* 값 없는 줄은 숨김(화면만 · 셈 · 열 · 단위수량은 그대로) · 표는 공용 카드 안(PLAN 143-3)
* 콘크리트(기계) · 모르터 1:3 열 = 늘 보임 · 손 단위수량 → 콘크리트소요(160-4) · 엑셀 손값과 다른
* 배합 · 할증은 표 아래 알림 한 줄만 · 맞춤 없는 사본 자재 · 채집석 공제 = 총괄 끝 줄(160-3 · 5)
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
@@ -23,6 +25,7 @@ import { clearState, readState, writeState } from "../A00_Common/b_page_state";
import {
blankUnitRows,
computeMaterialSheets,
DEDUCT_KEY,
MIX_FIELDS,
type Cells,
type MaterialOverrides,
@@ -49,7 +52,8 @@ const HINTS: Record<MaterialSheetId, string> = {
material:
"합계 = 끝수(수량 × (1 + 할증)) — 레미콘 ROUND 2 · 철근 · 와이어메쉬 ROUND 3 · 시멘트 ROUND 0 · 그 밖 올림 · 할증 % 기본 = 건설품셈 1-3-1 · 관급 파형강관 0 — 고칠 수 있음 · 모래 · 자갈 · 시멘트는 좌측 패널",
concrete: "㎥당 = 산림품셈 12-1-3 (B) 배합 · 건축 9-1-1 모르터 기본(무근은 빈칸) — 고칠 수 있음",
quantity: "수량 = 구조물 수량 × 단위수량 · 단위수량은 빈칸(사용자 입력) · 관매설은 1",
quantity:
"수량 = 구조물 수량 × 단위수량 · 단위수량은 빈칸(사용자 입력) · 관매설은 1 · 콘크리트(기계) · 모르터 1:3 단위수량 → 콘크리트소요",
};
const HIDDEN = "값 없는 줄은 숨김";
@@ -157,6 +161,34 @@ const MIX_LABEL: Record<(typeof MIX_FIELDS)[number], [string, string, string]> =
gravel: ["자갈", "㎥당(㎥)", "수량(㎥)"],
};
/** 2공구 엑셀 손값(조사 I) — 지금 값(품셈 근거 · 124)과 다르면 표 아래 알림 한 줄만(160-4) */
const EXCEL_MIX: Record<string, Cells> = {
c25: { cement: 346, sand: 0.51, gravel: 0.59 },
c40: { cement: 323, sand: 0.48, gravel: 0.65 },
c40_plain: { cement: 312, sand: 0.46, gravel: 0.7 },
m12: { cement: 680, sand: 0.98 },
m13: { cement: 510, sand: 1.1 },
};
const EXCEL_SURCHARGE: Cells = { sand: 6, gravel: 4, cement: 2, mixed_aggregate: 2 };
function excelDiffs(s: MaterialSheets): string {
const out: string[] = [];
for (const row of s.concrete.rows)
for (const f of MIX_FIELDS) {
const want = EXCEL_MIX[row.key]?.[f];
if (want != null && row.per[f] !== want)
out.push(
`${row.name} ${row.spec} ${MIX_LABEL[f][0]} ${row.per[f] ?? "빈칸"}(엑셀 ${want})`,
);
}
for (const row of s.material.rows) {
const want = EXCEL_SURCHARGE[row.key];
if (want != null && (row.surcharge ?? 0) !== want)
out.push(`${row.spec} 할증 ${row.surcharge ?? 0}%(엑셀 ${want}%)`);
}
return out.length ? `2공구 엑셀 손값과 다름 — ${out.join(" · ")} · 지금 값 = 품셈 근거` : "";
}
function concreteTable(s: MaterialSheets, o: MaterialOverrides): Table {
const { rows, total } = s.concrete;
const keep = shown(rows, (row) => row.qty != null || anyValue(row.amounts));
@@ -205,6 +237,7 @@ function concreteTable(s: MaterialSheets, o: MaterialOverrides): Table {
function totalTable(s: MaterialSheets, o: MaterialOverrides): Table {
const { rows } = s.material;
const lost = new Set(s.quantity.unmatched);
const keep = shown(rows, (row) => row.qty != null || row.amount != null);
return {
columns: [
@@ -225,12 +258,19 @@ function totalTable(s: MaterialSheets, o: MaterialOverrides): Table {
qty: row.qty,
surcharge: row.surcharge,
amount: row.amount,
note: row.panel ? "할증 = 좌측 패널" : "",
note: row.panel
? "할증 = 좌측 패널"
: row.key === DEDUCT_KEY
? "토공 잔토처리에서 뺌"
: lost.has(row.key)
? "2공구 맞춤 없음"
: "",
};
}),
total: null,
frozen: 2,
editable: (i, key) => key === "surcharge" && !rows[keep[i]].panel,
editable: (i, key) =>
key === "surcharge" && !rows[keep[i]].panel && rows[keep[i]].key !== DEDUCT_KEY,
target: (i) => [o.surcharge, rows[keep[i]].key, null],
};
}
@@ -312,11 +352,15 @@ export async function renderMaterialSheet(
},
});
table.root.dataset.b08Material = sheet;
const excel = note("");
excel.dataset.b08MaterialExcel = "";
const redraw = (): void => {
built = BUILD[sheet](sheets, overrides);
table.setRows(built.rows, built.total);
excel.textContent = sheet === "quantity" ? "" : excelDiffs(sheets);
};
host.replaceChildren(createCard({ body: [toolbar, table.root] }));
redraw();
host.replaceChildren(createCard({ body: [toolbar, table.root, excel] }));
onOptionsChange(host, () => {
sheets = calc();
redraw();